@ai-devkit/agent-manager 0.9.0 → 0.10.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.
@@ -6,61 +6,33 @@ import { listAgentProcesses, enrichProcesses } from '../utils/process';
6
6
  import { batchGetSessionFileBirthtimes } from '../utils/session';
7
7
  import type { SessionFile } from '../utils/session';
8
8
  import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
9
- /**
10
- * Entry in session JSONL file
11
- */
12
- interface ContentBlock {
13
- type?: string;
14
- text?: string;
15
- content?: string;
16
- name?: string;
17
- input?: Record<string, unknown>;
18
- tool_use_id?: string;
19
- is_error?: boolean;
20
- }
21
-
22
- interface SessionEntry {
23
- type?: string;
24
- timestamp?: string;
25
- cwd?: string;
26
- message?: {
27
- content?: string | ContentBlock[];
28
- };
29
- }
9
+ import { ClaudeSessionParser } from '../utils/ClaudeSessionParser';
10
+ import type { ClaudeSession } from '../utils/ClaudeSessionParser';
30
11
 
31
12
  /**
32
- * Entry in ~/.claude/sessions/<pid>.json written by Claude Code
13
+ * Entry in ~/.claude/sessions/<pid>.json written by Claude Code.
14
+ * Maps a running process to its session file via PID.
33
15
  */
34
16
  interface PidFileEntry {
35
17
  pid: number;
36
18
  sessionId: string;
37
19
  cwd: string;
38
- startedAt: number; // epoch milliseconds
20
+ /** Epoch milliseconds when the Claude Code process started */
21
+ startedAt: number;
39
22
  kind: string;
40
23
  entrypoint: string;
41
24
  }
42
25
 
43
26
  /**
44
- * A process directly matched to a session via PID file (authoritative path)
27
+ * A process directly matched to a session via PID file (authoritative path).
45
28
  */
46
29
  interface DirectMatch {
47
30
  process: ProcessInfo;
48
31
  sessionFile: SessionFile;
49
32
  }
50
33
 
51
- /**
52
- * Claude Code session information
53
- */
54
- interface ClaudeSession {
55
- sessionId: string;
56
- projectPath: string;
57
- lastCwd?: string;
58
- sessionStart: Date;
59
- lastActive: Date;
60
- lastEntryType?: string;
61
- isInterrupted: boolean;
62
- lastUserMessage?: string;
63
- }
34
+ /** Maximum allowed delta (ms) between process start time and PID file startedAt. */
35
+ const PID_FILE_STALENESS_MS = 60000;
64
36
 
65
37
  /**
66
38
  * Claude Code Adapter
@@ -77,16 +49,15 @@ export class ClaudeCodeAdapter implements AgentAdapter {
77
49
 
78
50
  private projectsDir: string;
79
51
  private sessionsDir: string;
52
+ private parser: ClaudeSessionParser;
80
53
 
81
54
  constructor() {
82
55
  const homeDir = process.env.HOME || process.env.USERPROFILE || '';
83
56
  this.projectsDir = path.join(homeDir, '.claude', 'projects');
84
57
  this.sessionsDir = path.join(homeDir, '.claude', 'sessions');
58
+ this.parser = new ClaudeSessionParser();
85
59
  }
86
60
 
87
- /**
88
- * Check if this adapter can handle a given process
89
- */
90
61
  canHandle(processInfo: ProcessInfo): boolean {
91
62
  return this.isClaudeExecutable(processInfo.command);
92
63
  }
@@ -97,9 +68,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
97
68
  return base === 'claude' || base === 'claude.exe';
98
69
  }
99
70
 
100
- /**
101
- * Detect running Claude Code agents
102
- */
103
71
  async detectAgents(): Promise<AgentInfo[]> {
104
72
  const processes = enrichProcesses(listAgentProcesses('claude'));
105
73
  if (processes.length === 0) {
@@ -125,7 +93,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
125
93
 
126
94
  // Build agents from direct (PID-file) matches
127
95
  for (const { process: proc, sessionFile } of direct) {
128
- const sessionData = this.readSession(sessionFile.filePath, sessionFile.resolvedCwd);
96
+ const sessionData = this.parser.readSession(sessionFile.filePath, sessionFile.resolvedCwd);
129
97
  if (sessionData) {
130
98
  agents.push(this.mapSessionToAgent(sessionData, proc, sessionFile));
131
99
  } else {
@@ -135,7 +103,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
135
103
 
136
104
  // Build agents from legacy matches
137
105
  for (const match of legacyMatches) {
138
- const sessionData = this.readSession(
106
+ const sessionData = this.parser.readSession(
139
107
  match.session.filePath,
140
108
  match.session.resolvedCwd,
141
109
  );
@@ -164,7 +132,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
164
132
  * via a single batched stat call across all directories.
165
133
  */
166
134
  private discoverSessions(processes: ProcessInfo[]): SessionFile[] {
167
- // Collect valid project dirs and map them back to their CWD
168
135
  const dirToCwd = new Map<string, string>();
169
136
 
170
137
  for (const proc of processes) {
@@ -184,10 +151,8 @@ export class ClaudeCodeAdapter implements AgentAdapter {
184
151
 
185
152
  if (dirToCwd.size === 0) return [];
186
153
 
187
- // Single batched stat call across all directories
188
154
  const files = batchGetSessionFileBirthtimes([...dirToCwd.keys()]);
189
155
 
190
- // Set resolvedCwd based on which project dir the file belongs to
191
156
  for (const file of files) {
192
157
  file.resolvedCwd = dirToCwd.get(file.projectDir) || '';
193
158
  }
@@ -203,7 +168,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
203
168
  * fallback — processes with no valid PID file (sent to legacy matching)
204
169
  *
205
170
  * Per-process fallback triggers on: file absent, malformed JSON,
206
- * stale startedAt (>60 s from proc.startTime), or missing JSONL.
171
+ * stale startedAt (>60s from proc.startTime), or missing JSONL.
207
172
  */
208
173
  private tryPidFileMatching(processes: ProcessInfo[]): {
209
174
  direct: DirectMatch[];
@@ -222,7 +187,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
222
187
  // Stale-file guard: reject PID files from a previous process with the same PID
223
188
  if (proc.startTime) {
224
189
  const deltaMs = Math.abs(proc.startTime.getTime() - entry.startedAt);
225
- if (deltaMs > 60000) {
190
+ if (deltaMs > PID_FILE_STALENESS_MS) {
226
191
  fallback.push(proc);
227
192
  continue;
228
193
  }
@@ -274,7 +239,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
274
239
  return {
275
240
  name: generateAgentName(processInfo.cwd, processInfo.pid),
276
241
  type: this.type,
277
- status: this.determineStatus(session),
242
+ status: this.parser.determineStatus(session),
278
243
  summary: session.lastUserMessage || 'Session started',
279
244
  pid: processInfo.pid,
280
245
  projectPath: sessionFile.resolvedCwd || processInfo.cwd || '',
@@ -297,320 +262,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
297
262
  };
298
263
  }
299
264
 
300
- /**
301
- * Parse a single session file into ClaudeSession
302
- */
303
- private readSession(
304
- filePath: string,
305
- projectPath: string,
306
- ): ClaudeSession | null {
307
- const sessionId = path.basename(filePath, '.jsonl');
308
-
309
- let content: string;
310
- try {
311
- content = fs.readFileSync(filePath, 'utf-8');
312
- } catch {
313
- return null;
314
- }
315
-
316
- const allLines = content.trim().split('\n');
317
- if (allLines.length === 0) {
318
- return null;
319
- }
320
-
321
- // Parse first line for sessionStart.
322
- // Claude Code may emit a "file-history-snapshot" as the first entry, which
323
- // stores its timestamp inside "snapshot.timestamp" rather than at the root.
324
- let sessionStart: Date | null = null;
325
- try {
326
- const firstEntry = JSON.parse(allLines[0]);
327
- const rawTs: string | undefined =
328
- firstEntry.timestamp || firstEntry.snapshot?.timestamp;
329
- if (rawTs) {
330
- const ts = new Date(rawTs);
331
- if (!Number.isNaN(ts.getTime())) {
332
- sessionStart = ts;
333
- }
334
- }
335
- } catch {
336
- /* skip */
337
- }
338
-
339
- // Parse all lines for session state (file already in memory)
340
- let lastEntryType: string | undefined;
341
- let lastActive: Date | undefined;
342
- let lastCwd: string | undefined;
343
- let isInterrupted = false;
344
- let lastUserMessage: string | undefined;
345
-
346
- for (const line of allLines) {
347
- try {
348
- const entry: SessionEntry = JSON.parse(line);
349
-
350
- if (entry.timestamp) {
351
- const ts = new Date(entry.timestamp);
352
- if (!Number.isNaN(ts.getTime())) {
353
- lastActive = ts;
354
- }
355
- }
356
-
357
- if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) {
358
- lastCwd = entry.cwd;
359
- }
360
-
361
- if (entry.type && !this.isMetadataEntryType(entry.type)) {
362
- lastEntryType = entry.type;
363
-
364
- if (entry.type === 'user') {
365
- const msgContent = entry.message?.content;
366
- isInterrupted =
367
- Array.isArray(msgContent) &&
368
- msgContent.some(
369
- (c) =>
370
- (c.type === 'text' &&
371
- c.text?.includes('[Request interrupted')) ||
372
- (c.type === 'tool_result' &&
373
- c.content?.includes('[Request interrupted')),
374
- );
375
-
376
- // Extract user message text for summary fallback
377
- const text = this.extractUserMessageText(msgContent);
378
- if (text) {
379
- lastUserMessage = text;
380
- }
381
- } else {
382
- isInterrupted = false;
383
- }
384
- }
385
- } catch {
386
- continue;
387
- }
388
- }
389
-
390
- return {
391
- sessionId,
392
- projectPath: projectPath || lastCwd || '',
393
- lastCwd,
394
- sessionStart: sessionStart || lastActive || new Date(),
395
- lastActive: lastActive || new Date(),
396
- lastEntryType,
397
- isInterrupted,
398
- lastUserMessage,
399
- };
400
- }
401
-
402
- /**
403
- * Determine agent status from session state
404
- */
405
- private determineStatus(session: ClaudeSession): AgentStatus {
406
- if (!session.lastEntryType) {
407
- return AgentStatus.UNKNOWN;
408
- }
409
-
410
- // No age-based IDLE override: every agent in the list is backed by
411
- // a running process (found via ps), so the entry type is the best
412
- // indicator of actual state.
413
-
414
- if (session.lastEntryType === 'user') {
415
- return session.isInterrupted
416
- ? AgentStatus.WAITING
417
- : AgentStatus.RUNNING;
418
- }
419
-
420
- if (
421
- session.lastEntryType === 'progress' ||
422
- session.lastEntryType === 'thinking'
423
- ) {
424
- return AgentStatus.RUNNING;
425
- }
426
-
427
- if (session.lastEntryType === 'assistant') {
428
- return AgentStatus.WAITING;
429
- }
430
-
431
- if (session.lastEntryType === 'system') {
432
- return AgentStatus.IDLE;
433
- }
434
-
435
- return AgentStatus.UNKNOWN;
436
- }
437
-
438
- /**
439
- * Extract meaningful text from a user message content.
440
- * Handles string and array formats, skill command expansion, and noise filtering.
441
- */
442
- private extractUserMessageText(
443
- content: string | Array<{ type?: string; text?: string }> | undefined,
444
- ): string | undefined {
445
- if (!content) {
446
- return undefined;
447
- }
448
-
449
- let raw: string | undefined;
450
-
451
- if (typeof content === 'string') {
452
- raw = content.trim();
453
- } else if (Array.isArray(content)) {
454
- for (const block of content) {
455
- if (block.type === 'text' && block.text?.trim()) {
456
- raw = block.text.trim();
457
- break;
458
- }
459
- }
460
- }
461
-
462
- if (!raw) {
463
- return undefined;
464
- }
465
-
466
- // Skill slash-command: extract /command-name and args
467
- if (raw.startsWith('<command-message>')) {
468
- return this.parseCommandMessage(raw);
469
- }
470
-
471
- // Expanded skill content: extract ARGUMENTS line if present, skip otherwise
472
- if (raw.startsWith('Base directory for this skill:')) {
473
- const argsMatch = raw.match(/\nARGUMENTS:\s*(.+)/);
474
- return argsMatch?.[1]?.trim() || undefined;
475
- }
476
-
477
- // Filter noise
478
- if (this.isNoiseMessage(raw)) {
479
- return undefined;
480
- }
481
-
482
- return raw;
483
- }
484
-
485
- /**
486
- * Parse a <command-message> string into "/command args" format.
487
- */
488
- private parseCommandMessage(raw: string): string | undefined {
489
- const nameMatch = raw.match(/<command-name>([^<]+)<\/command-name>/);
490
- const argsMatch = raw.match(/<command-args>([^<]+)<\/command-args>/);
491
- const name = nameMatch?.[1]?.trim();
492
- if (!name) {
493
- return undefined;
494
- }
495
- const args = argsMatch?.[1]?.trim();
496
- return args ? `${name} ${args}` : name;
497
- }
498
-
499
- /**
500
- * Check if a message is noise (not a meaningful user intent).
501
- */
502
- private isNoiseMessage(text: string): boolean {
503
- return (
504
- text.startsWith('[Request interrupted') ||
505
- text === 'Tool loaded.' ||
506
- text.startsWith('This session is being continued')
507
- );
508
- }
509
-
510
- /**
511
- * Check if an entry type is metadata (not conversation state).
512
- * These should not overwrite lastEntryType used for status determination.
513
- */
514
- private isMetadataEntryType(type: string): boolean {
515
- return type === 'last-prompt' || type === 'file-history-snapshot';
516
- }
517
-
518
- /**
519
- * Read the full conversation from a Claude Code session JSONL file.
520
- *
521
- * Default mode returns only text content from user/assistant/system messages.
522
- * Verbose mode also includes tool_use and tool_result blocks.
523
- */
524
265
  getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
525
- const verbose = options?.verbose ?? false;
526
-
527
- let content: string;
528
- try {
529
- content = fs.readFileSync(sessionFilePath, 'utf-8');
530
- } catch {
531
- return [];
532
- }
533
-
534
- const lines = content.trim().split('\n');
535
- const messages: ConversationMessage[] = [];
536
-
537
- for (const line of lines) {
538
- let entry: SessionEntry;
539
- try {
540
- entry = JSON.parse(line);
541
- } catch {
542
- continue;
543
- }
544
-
545
- const entryType = entry.type;
546
- if (!entryType || this.isMetadataEntryType(entryType)) continue;
547
- if (entryType === 'progress' || entryType === 'thinking') continue;
548
-
549
- let role: ConversationMessage['role'];
550
- if (entryType === 'user') {
551
- role = 'user';
552
- } else if (entryType === 'assistant') {
553
- role = 'assistant';
554
- } else if (entryType === 'system') {
555
- role = 'system';
556
- } else {
557
- continue;
558
- }
559
-
560
- const text = this.extractConversationContent(entry.message?.content, role, verbose);
561
- if (!text) continue;
562
-
563
- messages.push({
564
- role,
565
- content: text,
566
- timestamp: entry.timestamp,
567
- });
568
- }
569
-
570
- return messages;
266
+ return this.parser.getConversation(sessionFilePath, options);
571
267
  }
572
-
573
- /**
574
- * Extract displayable content from a message content field.
575
- */
576
- private extractConversationContent(
577
- content: string | ContentBlock[] | undefined,
578
- role: ConversationMessage['role'],
579
- verbose: boolean,
580
- ): string | undefined {
581
- if (!content) return undefined;
582
-
583
- if (typeof content === 'string') {
584
- const trimmed = content.trim();
585
- if (role === 'user' && this.isNoiseMessage(trimmed)) return undefined;
586
- return trimmed || undefined;
587
- }
588
-
589
- if (!Array.isArray(content)) return undefined;
590
-
591
- const parts: string[] = [];
592
-
593
- for (const block of content) {
594
- if (block.type === 'text' && block.text?.trim()) {
595
- if (role === 'user' && this.isNoiseMessage(block.text.trim())) continue;
596
- parts.push(block.text.trim());
597
- } else if (block.type === 'tool_use' && verbose) {
598
- const inputSummary = block.input?.file_path || block.input?.pattern || block.input?.command || '';
599
- parts.push(`[Tool: ${block.name}]${inputSummary ? ' ' + inputSummary : ''}`);
600
- } else if (block.type === 'tool_result' && verbose) {
601
- const truncated = this.truncateToolResult(block.content || '');
602
- const prefix = block.is_error ? '[Tool Error]' : '[Tool Result]';
603
- parts.push(`${prefix} ${truncated}`);
604
- }
605
- }
606
-
607
- return parts.length > 0 ? parts.join('\n') : undefined;
608
- }
609
-
610
- private truncateToolResult(content: string, maxLength = 200): string {
611
- const firstLine = content.split('\n')[0] || '';
612
- if (firstLine.length <= maxLength) return firstLine;
613
- return firstLine.slice(0, maxLength - 3) + '...';
614
- }
615
-
616
268
  }
@@ -1,8 +1,7 @@
1
- import { exec, execFile } from 'child_process';
1
+ import { execFile } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import { getProcessTty } from '../utils/process';
4
4
 
5
- const execAsync = promisify(exec);
6
5
  const execFileAsync = promisify(execFile);
7
6
 
8
7
  export enum TerminalType {
@@ -18,6 +17,10 @@ export interface TerminalLocation {
18
17
  tty: string; // e.g., "/dev/ttys030"
19
18
  }
20
19
 
20
+ function escapeAppleScript(text: string): string {
21
+ return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
22
+ }
23
+
21
24
  export class TerminalFocusManager {
22
25
  /**
23
26
  * Find the terminal location (emulator info) for a given process ID
@@ -67,17 +70,16 @@ export class TerminalFocusManager {
67
70
  default:
68
71
  return false;
69
72
  }
70
- } catch (error) {
73
+ } catch {
71
74
  return false;
72
75
  }
73
76
  }
74
77
 
75
78
  private async findTmuxPane(tty: string): Promise<TerminalLocation | null> {
76
79
  try {
77
- // List all panes with their TTYs and identifiers
78
- // Format: /dev/ttys001|my-session:1.1
79
- // using | as separator to handle spaces in session names
80
- const { stdout } = await execAsync("tmux list-panes -a -F '#{pane_tty}|#{session_name}:#{window_index}.#{pane_index}'");
80
+ const { stdout } = await execFileAsync('tmux', [
81
+ 'list-panes', '-a', '-F', '#{pane_tty}|#{session_name}:#{window_index}.#{pane_index}'
82
+ ]);
81
83
 
82
84
  const lines = stdout.trim().split('\n');
83
85
  for (const line of lines) {
@@ -91,7 +93,7 @@ export class TerminalFocusManager {
91
93
  };
92
94
  }
93
95
  }
94
- } catch (error) {
96
+ } catch {
95
97
  // tmux might not be installed or running
96
98
  }
97
99
  return null;
@@ -100,15 +102,19 @@ export class TerminalFocusManager {
100
102
  private async findITerm2Session(tty: string): Promise<TerminalLocation | null> {
101
103
  try {
102
104
  // Check if iTerm2 is running first to avoid launching it
103
- const { stdout: isRunning } = await execAsync('pgrep -x iTerm2 || echo "no"');
104
- if (isRunning.trim() === "no") return null;
105
+ await execFileAsync('pgrep', ['-x', 'iTerm2']);
106
+ } catch {
107
+ return null;
108
+ }
105
109
 
110
+ try {
111
+ const escapedTty = escapeAppleScript(tty);
106
112
  const script = `
107
113
  tell application "iTerm"
108
114
  repeat with w in windows
109
115
  repeat with t in tabs of w
110
116
  repeat with s in sessions of t
111
- if tty of s is "${tty}" then
117
+ if tty of s is "${escapedTty}" then
112
118
  return "found"
113
119
  end if
114
120
  end repeat
@@ -117,7 +123,7 @@ export class TerminalFocusManager {
117
123
  end tell
118
124
  `;
119
125
 
120
- const { stdout } = await execAsync(`osascript -e '${script}'`);
126
+ const { stdout } = await execFileAsync('osascript', ['-e', script]);
121
127
  if (stdout.trim() === "found") {
122
128
  return {
123
129
  type: TerminalType.ITERM2,
@@ -125,23 +131,27 @@ export class TerminalFocusManager {
125
131
  tty
126
132
  };
127
133
  }
128
- } catch (error) {
129
- // iTerm2 not found or script failed
134
+ } catch {
135
+ // iTerm2 script failed
130
136
  }
131
137
  return null;
132
138
  }
133
139
 
134
140
  private async findTerminalAppWindow(tty: string): Promise<TerminalLocation | null> {
135
141
  try {
136
- // Check if Terminal is running
137
- const { stdout: isRunning } = await execAsync('ps -eo pid=,comm= | grep "Terminal.app" || echo "no"');
138
- if (isRunning.trim() === "no") return null;
142
+ // Check if Terminal.app is running
143
+ await execFileAsync('pgrep', ['-x', 'Terminal']);
144
+ } catch {
145
+ return null;
146
+ }
139
147
 
148
+ try {
149
+ const escapedTty = escapeAppleScript(tty);
140
150
  const script = `
141
151
  tell application "Terminal"
142
152
  repeat with w in windows
143
153
  repeat with t in tabs of w
144
- if tty of t is "${tty}" then
154
+ if tty of t is "${escapedTty}" then
145
155
  return "found"
146
156
  end if
147
157
  end repeat
@@ -149,7 +159,7 @@ export class TerminalFocusManager {
149
159
  end tell
150
160
  `;
151
161
 
152
- const { stdout } = await execAsync(`osascript -e '${script}'`);
162
+ const { stdout } = await execFileAsync('osascript', ['-e', script]);
153
163
  if (stdout.trim() === "found") {
154
164
  return {
155
165
  type: TerminalType.TERMINAL_APP,
@@ -157,8 +167,8 @@ export class TerminalFocusManager {
157
167
  tty
158
168
  };
159
169
  }
160
- } catch (error) {
161
- // Terminal not found or script failed
170
+ } catch {
171
+ // Terminal.app script failed
162
172
  }
163
173
  return null;
164
174
  }
@@ -167,19 +177,20 @@ export class TerminalFocusManager {
167
177
  try {
168
178
  await execFileAsync('tmux', ['switch-client', '-t', identifier]);
169
179
  return true;
170
- } catch (error) {
180
+ } catch {
171
181
  return false;
172
182
  }
173
183
  }
174
184
 
175
185
  private async focusITerm2Session(tty: string): Promise<boolean> {
186
+ const escapedTty = escapeAppleScript(tty);
176
187
  const script = `
177
188
  tell application "iTerm"
178
189
  activate
179
190
  repeat with w in windows
180
191
  repeat with t in tabs of w
181
192
  repeat with s in sessions of t
182
- if tty of s is "${tty}" then
193
+ if tty of s is "${escapedTty}" then
183
194
  select s
184
195
  return "true"
185
196
  end if
@@ -188,17 +199,18 @@ export class TerminalFocusManager {
188
199
  end repeat
189
200
  end tell
190
201
  `;
191
- const { stdout } = await execAsync(`osascript -e '${script}'`);
202
+ const { stdout } = await execFileAsync('osascript', ['-e', script]);
192
203
  return stdout.trim() === "true";
193
204
  }
194
205
 
195
206
  private async focusTerminalAppWindow(tty: string): Promise<boolean> {
207
+ const escapedTty = escapeAppleScript(tty);
196
208
  const script = `
197
209
  tell application "Terminal"
198
210
  activate
199
211
  repeat with w in windows
200
212
  repeat with t in tabs of w
201
- if tty of t is "${tty}" then
213
+ if tty of t is "${escapedTty}" then
202
214
  set index of w to 1
203
215
  set selected tab of w to t
204
216
  return "true"
@@ -207,7 +219,7 @@ export class TerminalFocusManager {
207
219
  end repeat
208
220
  end tell
209
221
  `;
210
- const { stdout } = await execAsync(`osascript -e '${script}'`);
222
+ const { stdout } = await execFileAsync('osascript', ['-e', script]);
211
223
  return stdout.trim() === "true";
212
224
  }
213
225
  }