sigrank 0.12.4 → 0.13.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 CHANGED
@@ -67,7 +67,7 @@ sigrank
67
67
  ## Commands
68
68
 
69
69
  ```
70
- ⊙ SigRank CLI v0.12.3
70
+ ⊙ SigRank CLI v0.13.0
71
71
 
72
72
  Default (no args)
73
73
  sigrank unified dashboard: cascade + token pillars + board
package/cli.mjs CHANGED
@@ -28,10 +28,31 @@ import { callTool, DEFAULT_API_BASE } from './tools.mjs'
28
28
  import { classify } from './cascade.mjs'
29
29
  import { ensureIdentity, keystorePath } from './keystore.mjs'
30
30
  import { submitSignedWindow } from './submit.mjs'
31
- import { execSync } from 'child_process'
32
- import { existsSync, readFileSync, writeFileSync } from 'fs'
31
+ import { execFile } from 'child_process'
32
+ import { existsSync, readFileSync } from 'fs'
33
33
  import os from 'os'
34
34
  import path from 'path'
35
+ import { fileURLToPath } from 'url'
36
+
37
+ // Resolve local node_modules/.bin for bundled deps (ccusage, tokscale)
38
+ const _pkgRoot = path.dirname(fileURLToPath(import.meta.url))
39
+ const _localBin = path.join(_pkgRoot, 'node_modules', '.bin')
40
+ const _envPath = `${_localBin}${process.env.PATH ? ':' + process.env.PATH : ''}`
41
+
42
+ // ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync for
43
+ // defense-in-depth (shell injection prevention). execFile passes args as an
44
+ // array, so no shell parsing occurs — even if platform contained special chars,
45
+ // they'd be treated as literal arguments, not shell commands.
46
+ // BIN FIX (2026-06-27): PATH includes local node_modules/.bin so bundled deps
47
+ // are found even when sigrank isn't globally installed.
48
+ function execFileAsync(cmd, args, timeoutMs) {
49
+ return new Promise((resolve, reject) => {
50
+ execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: _envPath } }, (err, stdout) => {
51
+ if (err) reject(err)
52
+ else resolve(stdout.toString())
53
+ })
54
+ })
55
+ }
35
56
 
36
57
  // ── ANSI helpers (no chalk dep) ────────────────────────────────────────────
37
58
  const ESC = '\x1b['
@@ -407,11 +428,10 @@ async function runMe({ platform = 'claude', compare = false } = {}) {
407
428
  // identical to _freshCcusage and could be consolidated in a future pass if compare
408
429
  // switches to live data — but that's a behavior change, not a refactor.
409
430
 
410
- function ccusagePillars(platform = 'claude') {
431
+ async function ccusagePillars(platform = 'claude') {
411
432
  // ccusage <platform> daily --json → sum by window
412
433
  try {
413
- const cmd = platform === 'claude' ? 'ccusage claude daily --json' : `ccusage ${platform} daily --json`
414
- const raw = execSync(cmd, { timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
434
+ const raw = await execFileAsync('ccusage', [platform, 'daily', '--json'], 15000)
415
435
  const data = JSON.parse(raw)
416
436
  const rows = data.daily ?? data // ccusage may return {daily:[...]} or [...]
417
437
 
@@ -448,8 +468,28 @@ function ccusagePillars(platform = 'claude') {
448
468
  }
449
469
  }
450
470
 
451
- function tokscalePillars(platform = 'claude') {
452
- // Read tokscale_report.json — all-time only (no timestamps in export)
471
+ async function tokscalePillars(platform = 'claude') {
472
+ // Try the bundled tokscale CLI first (fresh data), fall back to saved report file.
473
+ try {
474
+ const raw = await execFileAsync('tokscale', ['models', '--json'], 60000)
475
+ const data = JSON.parse(raw)
476
+ const entries = Array.isArray(data?.entries) ? data.entries : (Array.isArray(data) ? data : [])
477
+ const rows = entries.filter(e =>
478
+ e && e.client === platform &&
479
+ e.model !== '<synthetic>' && e.model !== 'unknown' &&
480
+ ((Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0)
481
+ )
482
+ if (rows.length) {
483
+ const p = rows.reduce((acc, e) => ({
484
+ input: acc.input + (Number(e.input) || 0),
485
+ output: acc.output + (Number(e.output) || 0),
486
+ cacheCreate: acc.cacheCreate + (Number(e.cacheWrite) || 0),
487
+ cacheRead: acc.cacheRead + (Number(e.cacheRead) || 0),
488
+ }), { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 })
489
+ return { all: p }
490
+ }
491
+ } catch { /* fall through to file-based read */ }
492
+ // Fallback: read saved tokscale_report.json — all-time only (no timestamps in export)
453
493
  const reportPath = path.join(os.homedir(), 'tokscale_report.json')
454
494
  if (!existsSync(reportPath)) return null
455
495
  try {
@@ -494,28 +534,37 @@ function appPillars() {
494
534
  }
495
535
  }
496
536
 
497
- function tokenDashPillars() {
537
+ async function tokenDashPillars() {
498
538
  const dbPath = path.join(os.homedir(), '.claude', 'token-dashboard.db')
499
539
  if (!existsSync(dbPath)) return null
540
+ // Read directly with sqlite3 — no python script needed (bundled dep).
541
+ // Windowed queries using sqlite3's datetime() function.
542
+ const cf = "(model LIKE '%claude%' OR model LIKE '%fable%' OR model LIKE '%sonnet%' OR model LIKE '%opus%' OR model LIKE '%haiku%')"
500
543
  try {
501
- const tmpScript = path.join(os.tmpdir(), 'sigrank_td_query.py')
502
- writeFileSync(tmpScript, `
503
- import sqlite3, json, sys
504
- from datetime import datetime, timezone, timedelta
505
- db = sqlite3.connect(sys.argv[1])
506
- cf = "(model LIKE '%claude%' OR model LIKE '%fable%' OR model LIKE '%sonnet%' OR model LIKE '%opus%' OR model LIKE '%haiku%')"
507
- out = {}
508
- for win, days in [('7d',7),('30d',30),('90d',90)]:
509
- since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
510
- r = db.execute(f"SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens+cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages WHERE timestamp>=? AND {cf}",(since,)).fetchone()
511
- out[win] = {'input':r[0] or 0,'output':r[1] or 0,'cacheCreate':r[2] or 0,'cacheRead':r[3] or 0}
512
- r = db.execute(f"SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens+cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages WHERE {cf}").fetchone()
513
- out['all'] = {'input':r[0] or 0,'output':r[1] or 0,'cacheCreate':r[2] or 0,'cacheRead':r[3] or 0}
514
- print(json.dumps(out))
515
- `)
516
- const raw = execSync(`python3 "${tmpScript}" "${dbPath}"`,
517
- { timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
518
- return JSON.parse(raw)
544
+ const wins = [
545
+ ['7d', "datetime('now','-7 days')"],
546
+ ['30d', "datetime('now','-30 days')"],
547
+ ['90d', "datetime('now','-90 days')"],
548
+ ]
549
+ const sql = (cutoff) =>
550
+ `SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens+cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages WHERE timestamp>=${cutoff} AND ${cf}`
551
+ const allSql = `SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens+cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages WHERE ${cf}`
552
+ const [d7, d30, d90, dAll] = await Promise.all([
553
+ execFileAsync('sqlite3', [dbPath, sql(wins[0][1])], 5000),
554
+ execFileAsync('sqlite3', [dbPath, sql(wins[1][1])], 5000),
555
+ execFileAsync('sqlite3', [dbPath, sql(wins[2][1])], 5000),
556
+ execFileAsync('sqlite3', [dbPath, allSql], 5000),
557
+ ])
558
+ const parse = (raw) => {
559
+ const [i,o,cw,cr] = raw.trim().split('|').map(Number)
560
+ return { input: i||0, output: o||0, cacheCreate: cw||0, cacheRead: cr||0 }
561
+ }
562
+ return {
563
+ '7d': parse(d7),
564
+ '30d': parse(d30),
565
+ '90d': parse(d90),
566
+ all: parse(dAll),
567
+ }
519
568
  } catch {
520
569
  return null
521
570
  }
@@ -560,13 +609,13 @@ function cascadeFromPillars(p) {
560
609
  async function runCompare({ platform = 'claude' } = {}) {
561
610
  write(HIDE_CURSOR)
562
611
 
563
- // Pull all five sources in parallel
612
+ // Pull all five sources in parallel (verifiers now async via execFile)
564
613
  writeln(` ${dim('reading all 5 sources…')}`)
565
614
  const [ccPillars, tpData, tdPillars, tsPillars, apPillars] = await Promise.all([
566
- Promise.resolve(ccusagePillars(platform)),
615
+ ccusagePillars(platform).catch(() => null),
567
616
  callTool('tokenpull', { platform }).catch(() => null),
568
- Promise.resolve(platform === 'claude' ? tokenDashPillars() : null),
569
- Promise.resolve(tokscalePillars(platform)),
617
+ (platform === 'claude' ? tokenDashPillars() : Promise.resolve(null)).catch(() => null),
618
+ tokscalePillars(platform).catch(() => null),
570
619
  Promise.resolve(platform === 'claude' ? appPillars() : null),
571
620
  ])
572
621
  write(CURSOR_UP(1) + ERASE_LINE)
@@ -1154,8 +1203,8 @@ async function runSigRank() {
1154
1203
  if (k === 'q' || key === '\u0003') { // q or ctrl+c
1155
1204
  resolve()
1156
1205
  } else if (k === 'b') {
1157
- const { execSync: es } = await import('child_process')
1158
- try { es('open https://signalaf.com', { stdio: 'ignore' }) } catch { }
1206
+ // ASYNC FIX (2026-06-27): execFile instead of dynamic execSync import
1207
+ execFile('open', ['https://signalaf.com'], { stdio: 'ignore' }, () => {})
1159
1208
  resolve()
1160
1209
  } else if (k === 's') {
1161
1210
  const id = ensureIdentity()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.12.4",
3
+ "version": "0.13.0",
4
4
  "description": "SigRank MCP server — the yield cascade + live leaderboard as MCP tools any agent can call",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,6 +41,9 @@
41
41
  "LICENSE"
42
42
  ],
43
43
  "dependencies": {
44
- "@modelcontextprotocol/sdk": "^1.0.0"
44
+ "@modelcontextprotocol/sdk": "^1.0.0",
45
+ "@zhangferry-dev/tokendash": "^1.7.5",
46
+ "ccusage": "^20.0.14",
47
+ "tokscale": "^4.0.4"
45
48
  }
46
49
  }
package/tokenpull.mjs CHANGED
@@ -17,21 +17,31 @@
17
17
 
18
18
  import { readdir, readFile, lstat } from 'node:fs/promises'
19
19
  import { existsSync } from 'node:fs'
20
- import { execSync, execFile } from 'node:child_process'
21
- import { join } from 'node:path'
20
+ import { execFile } from 'node:child_process'
21
+ import { join, dirname } from 'node:path'
22
22
  import { homedir } from 'node:os'
23
+ import { fileURLToPath } from 'node:url'
23
24
  import { ADAPTERS } from './adapters.mjs'
24
25
 
25
26
  const DAY_MS = 86_400_000
26
27
 
28
+ // Resolve the package root for finding bundled binaries (ccusage, tokscale, etc.)
29
+ const _pkgRoot = join(dirname(fileURLToPath(import.meta.url)))
30
+ const _localBin = join(_pkgRoot, 'node_modules', '.bin')
31
+ // Prepend local node_modules/.bin to PATH so bundled deps are found even when
32
+ // not globally installed (e.g., npx sigrank, local dev).
33
+ const _envPath = `${_localBin}${process.env.PATH ? ':' + process.env.PATH : ''}`
34
+
27
35
  // ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync in the
28
36
  // fresh verifier readers. execSync blocks the entire Node event loop (no key handling,
29
37
  // no screen repaint for up to 90s during the tokendash scan). execFile is async: the
30
38
  // event loop keeps running, so the TUI stays responsive while external commands run.
31
39
  // Returns stdout as a string. Rejects on error or timeout (caller catches → null).
40
+ // BIN FIX (2026-06-27): PATH includes local node_modules/.bin so bundled deps
41
+ // (ccusage, tokscale) are found even when sigrank isn't globally installed.
32
42
  function execFileAsync(cmd, args, timeoutMs) {
33
43
  return new Promise((resolve, reject) => {
34
- execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
44
+ execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: _envPath } }, (err, stdout) => {
35
45
  if (err) reject(err)
36
46
  else resolve(stdout.toString())
37
47
  })
@@ -354,13 +364,9 @@ async function _freshCcusage(platform = 'claude') {
354
364
  async function _freshTokendash(platform = 'claude') {
355
365
  if (platform !== 'claude') return null
356
366
  const dbPath = join(homedir(), '.claude', 'token-dashboard.db')
357
- const scanCli = join(homedir(), 'Desktop', 'token-dashboard', 'cli.py')
358
- // Step 1 — refresh the db (best-effort; failures must not abort the read).
359
- if (existsSync(scanCli)) {
360
- try { await execFileAsync('python3', [scanCli, 'scan'], 90000) }
361
- catch { /* continue: read whatever the db currently has */ }
362
- }
363
- // Step 2 — read the (now-refreshed) db. all-time only; the db doesn't expose windowing here.
367
+ // Read the token-dashboard DB directly with sqlite3 (no external python scan
368
+ // needed — the DB is created by the tokendash dashboard, now bundled as a dep).
369
+ // all-time only; the db doesn't expose windowing here.
364
370
  if (!existsSync(dbPath)) return null
365
371
  try {
366
372
  const raw = await execFileAsync('sqlite3', [dbPath,
@@ -372,8 +378,9 @@ async function _freshTokendash(platform = 'claude') {
372
378
  } catch { return null }
373
379
  }
374
380
 
375
- // tokscale: run `bunx tokscale@latest models --json` and sum the per-(client,model) rows for
376
- // the requested platform. The JSON shape (verified live) is:
381
+ // tokscale: run `tokscale models --json` (bundled as npm dep — no bunx needed)
382
+ // and sum the per-(client,model) rows for the requested platform. The JSON shape
383
+ // (verified live) is:
377
384
  // { entries: [ { client, model, input, output, cacheRead, cacheWrite, ... }, ... ], ... }
378
385
  // `client` maps directly to our platform name. Synthetic/unknown model rows are dropped (they
379
386
  // carry no real usage). all-time only — tokscale's models view is not windowed. If no row
@@ -383,7 +390,7 @@ async function _freshTokendash(platform = 'claude') {
383
390
  // Now uses execFile (async) so the TUI stays responsive.
384
391
  async function _freshTokscale(platform = 'claude') {
385
392
  try {
386
- const raw = await execFileAsync('bunx', ['tokscale@latest', 'models', '--json'], 60000)
393
+ const raw = await execFileAsync('tokscale', ['models', '--json'], 60000)
387
394
  const data = JSON.parse(raw)
388
395
  const entries = Array.isArray(data?.entries) ? data.entries : (Array.isArray(data) ? data : [])
389
396
  const rows = entries.filter((e) =>
package/tools.mjs CHANGED
@@ -15,10 +15,31 @@ import { ALL_PLATFORMS } from './adapters.mjs'
15
15
  import { ensureIdentity, recordEnrollment } from './keystore.mjs'
16
16
  import { submitSignedWindow } from './submit.mjs'
17
17
  import { createHash } from 'node:crypto'
18
- import { execSync } from 'node:child_process'
18
+ import { execFile } from 'node:child_process'
19
19
  import { existsSync, readFileSync } from 'node:fs'
20
20
  import os from 'node:os'
21
21
  import path from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+
24
+ // Resolve local node_modules/.bin for bundled deps (ccusage, tokscale)
25
+ const _pkgRoot = path.dirname(fileURLToPath(import.meta.url))
26
+ const _localBin = path.join(_pkgRoot, 'node_modules', '.bin')
27
+ const _envPath = `${_localBin}${process.env.PATH ? ':' + process.env.PATH : ''}`
28
+
29
+ // ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync for
30
+ // defense-in-depth (shell injection prevention + non-blocking). The platform param
31
+ // is enum-validated at the MCP schema level, but execFile also prevents shell
32
+ // interpolation attacks by passing args as an array (no shell parsing).
33
+ // BIN FIX (2026-06-27): PATH includes local node_modules/.bin so bundled deps
34
+ // are found even when sigrank isn't globally installed.
35
+ function execFileAsync(cmd, args, timeoutMs) {
36
+ return new Promise((resolve, reject) => {
37
+ execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: _envPath } }, (err, stdout) => {
38
+ if (err) reject(err)
39
+ else resolve(stdout.toString())
40
+ })
41
+ })
42
+ }
22
43
 
23
44
  // ── Verifier readers (sync, on-device, token-only) ────────────────────────────
24
45
  // These mirror the implementations in cli.mjs / tui.mjs without the circular import.
@@ -29,9 +50,9 @@ import path from 'node:path'
29
50
  // (bunx tokscale, scan+read tokendash) for the TUI/CLI dashboard. Different data sources =
30
51
  // different behavior; do NOT merge without understanding the trade-off.
31
52
 
32
- function _ccusagePillars(platform = 'claude') {
53
+ async function _ccusagePillars(platform = 'claude') {
33
54
  try {
34
- const raw = execSync(`ccusage ${platform} daily --json`, { timeout: 15000, stdio: ['ignore','pipe','ignore'] }).toString()
55
+ const raw = await execFileAsync('ccusage', [platform, 'daily', '--json'], 15000)
35
56
  const rows = JSON.parse(raw)?.daily ?? JSON.parse(raw)
36
57
  const now = Date.now()
37
58
  const result = {}
@@ -57,20 +78,36 @@ function _ccusagePillars(platform = 'claude') {
57
78
  } catch { return null }
58
79
  }
59
80
 
60
- function _tokenDashPillars() {
81
+ async function _tokenDashPillars() {
61
82
  const dbPath = path.join(os.homedir(), '.claude', 'token-dashboard.db')
62
83
  if (!existsSync(dbPath)) return null
63
84
  try {
64
- const raw = execSync(
65
- `sqlite3 "${dbPath}" "SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages"`,
66
- { timeout: 5000, stdio: ['ignore','pipe','ignore'] }
67
- ).toString().trim()
68
- const [i,o,cw,cr] = raw.split('|').map(Number)
85
+ const raw = await execFileAsync('sqlite3', [dbPath,
86
+ 'SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages'
87
+ ], 5000)
88
+ const [i,o,cw,cr] = raw.trim().split('|').map(Number)
69
89
  return { all: { input: i||0, output: o||0, cacheCreate: cw||0, cacheRead: cr||0 } }
70
90
  } catch { return null }
71
91
  }
72
92
 
73
- function _tokscalePillars(platform = 'claude') {
93
+ async function _tokscalePillars(platform = 'claude') {
94
+ // Try the bundled tokscale CLI first (fresh data), fall back to saved report file.
95
+ try {
96
+ const raw = await execFileAsync('tokscale', ['models', '--json'], 60000)
97
+ const data = JSON.parse(raw)
98
+ const entries = Array.isArray(data?.entries) ? data.entries : (Array.isArray(data) ? data : [])
99
+ const rows = entries.filter(e =>
100
+ e && e.client === platform && e.model !== '<synthetic>' && e.model !== 'unknown' && ((Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0)
101
+ )
102
+ if (rows.length) {
103
+ const acc = rows.reduce((a,e) => ({
104
+ input: a.input+(Number(e.input)||0), output: a.output+(Number(e.output)||0),
105
+ cacheCreate: a.cacheCreate+(Number(e.cacheWrite)||0), cacheRead: a.cacheRead+(Number(e.cacheRead)||0),
106
+ }), { input:0, output:0, cacheCreate:0, cacheRead:0 })
107
+ return { all: acc }
108
+ }
109
+ } catch { /* fall through to file-based read */ }
110
+ // Fallback: read saved tokscale_report.json if it exists
74
111
  const p = path.join(os.homedir(), 'tokscale_report.json')
75
112
  if (!existsSync(p)) return null
76
113
  try {
@@ -578,12 +615,12 @@ export async function callTool(name, args, opts = {}) {
578
615
  const platform = args?.platform || 'claude'
579
616
  const WINS = ['7d', '30d', '90d', 'all']
580
617
 
581
- // Pull all four sources in parallel (verifiers are sync, wrap in Promise.resolve)
618
+ // Pull all four sources in parallel (verifiers are now async via execFile)
582
619
  const [tpResult, ccPillars, tdPillars, tsPillars] = await Promise.all([
583
620
  pullByPlatform(platform, opts).catch(() => null),
584
- Promise.resolve(_ccusagePillars(platform)),
585
- Promise.resolve(platform === 'claude' ? _tokenDashPillars() : null),
586
- Promise.resolve(_tokscalePillars(platform)),
621
+ _ccusagePillars(platform).catch(() => null),
622
+ (platform === 'claude' ? _tokenDashPillars() : Promise.resolve(null)).catch(() => null),
623
+ _tokscalePillars(platform).catch(() => null),
587
624
  ])
588
625
 
589
626
  // Build tokenpull window lookup
package/tui.mjs CHANGED
@@ -16,7 +16,7 @@ import { callTool, DEFAULT_API_BASE } from './tools.mjs'
16
16
  import { freshVerifierPillars } from './tokenpull.mjs'
17
17
  import { isSignedIn, isCodeChar } from './connect.mjs'
18
18
  import { loadIdentity, clearIdentity } from './keystore.mjs'
19
- import { execSync } from 'child_process'
19
+ import { execFile } from 'child_process'
20
20
  import { existsSync, readFileSync } from 'fs'
21
21
  import os from 'os'
22
22
  import path from 'path'
@@ -1758,7 +1758,9 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1758
1758
  if (watchPlatform && watchPlatform !== 'all') watchCmd += ` --platform ${watchPlatform}`
1759
1759
  if (watchWindow && watchWindow !== 'all-windows') watchCmd += ` --window ${watchWindow}`
1760
1760
  watchCmd += ` --refresh ${watchRefresh}`
1761
- execSync(`osascript -e 'tell application "Terminal" to do script "${watchCmd}"'`, { stdio: 'ignore' })
1761
+ // ASYNC FIX (2026-06-27): execFile instead of execSync — no shell
1762
+ // interpolation. osascript args passed as array (defense-in-depth).
1763
+ execFile('osascript', ['-e', `tell application "Terminal" to do script "${watchCmd}"`], { stdio: 'ignore' }, () => {})
1762
1764
  const scope = (!watchPlatform || watchPlatform === 'all') && (!watchWindow || watchWindow === 'all-windows')
1763
1765
  ? 'all platforms × all windows' : 'focused'
1764
1766
  status = `watcher launched (${scope}, ${watchRefresh}s) in a new window`