@ai-devkit/agent-manager 0.9.0 → 0.11.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.
Files changed (63) hide show
  1. package/dist/AgentManager.d.ts +21 -1
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +47 -0
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/adapters/AgentAdapter.d.ts +66 -0
  6. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  7. package/dist/adapters/ClaudeCodeAdapter.d.ts +14 -43
  8. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  9. package/dist/adapters/ClaudeCodeAdapter.js +60 -275
  10. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  11. package/dist/adapters/CodexAdapter.d.ts +14 -1
  12. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  13. package/dist/adapters/CodexAdapter.js +105 -6
  14. package/dist/adapters/CodexAdapter.js.map +1 -1
  15. package/dist/adapters/GeminiCliAdapter.d.ts +9 -1
  16. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  17. package/dist/adapters/GeminiCliAdapter.js +77 -6
  18. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
  23. package/dist/terminal/TerminalFocusManager.js +36 -27
  24. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  25. package/dist/terminal/TtyWriter.d.ts.map +1 -1
  26. package/dist/terminal/TtyWriter.js +3 -12
  27. package/dist/terminal/TtyWriter.js.map +1 -1
  28. package/dist/utils/ClaudeSessionParser.d.ts +114 -0
  29. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -0
  30. package/dist/utils/ClaudeSessionParser.js +377 -0
  31. package/dist/utils/ClaudeSessionParser.js.map +1 -0
  32. package/dist/utils/applescript.d.ts +6 -0
  33. package/dist/utils/applescript.d.ts.map +1 -0
  34. package/dist/utils/applescript.js +14 -0
  35. package/dist/utils/applescript.js.map +1 -0
  36. package/dist/utils/process.d.ts +3 -4
  37. package/dist/utils/process.d.ts.map +1 -1
  38. package/dist/utils/process.js +11 -15
  39. package/dist/utils/process.js.map +1 -1
  40. package/dist/utils/session.d.ts +34 -5
  41. package/dist/utils/session.d.ts.map +1 -1
  42. package/dist/utils/session.js +90 -44
  43. package/dist/utils/session.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/AgentManager.ts +66 -4
  46. package/src/__tests__/AgentManager.test.ts +134 -2
  47. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +188 -32
  48. package/src/__tests__/adapters/CodexAdapter.test.ts +123 -3
  49. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +133 -0
  50. package/src/__tests__/utils/ClaudeSessionParser.test.ts +195 -0
  51. package/src/__tests__/utils/process.test.ts +27 -27
  52. package/src/__tests__/utils/session.test.ts +79 -43
  53. package/src/adapters/AgentAdapter.ts +76 -0
  54. package/src/adapters/ClaudeCodeAdapter.ts +82 -356
  55. package/src/adapters/CodexAdapter.ts +126 -8
  56. package/src/adapters/GeminiCliAdapter.ts +102 -7
  57. package/src/index.ts +9 -1
  58. package/src/terminal/TerminalFocusManager.ts +35 -26
  59. package/src/terminal/TtyWriter.ts +1 -11
  60. package/src/utils/ClaudeSessionParser.ts +437 -0
  61. package/src/utils/applescript.ts +10 -0
  62. package/src/utils/process.ts +21 -24
  63. package/src/utils/session.ts +86 -45
@@ -0,0 +1,437 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import type { ConversationMessage } from '../adapters/AgentAdapter';
4
+ import { AgentStatus } from '../adapters/AgentAdapter';
5
+
6
+ /**
7
+ * Content block within a Claude Code JSONL message entry.
8
+ * Handles text, tool_use, and tool_result block types.
9
+ */
10
+ export interface ContentBlock {
11
+ type?: string;
12
+ text?: string;
13
+ content?: string;
14
+ name?: string;
15
+ input?: Record<string, unknown>;
16
+ tool_use_id?: string;
17
+ is_error?: boolean;
18
+ }
19
+
20
+ /**
21
+ * A single line entry in a Claude Code session JSONL file.
22
+ *
23
+ * Each line is an independent JSON object with a type discriminator:
24
+ * - "user" / "assistant" / "system" — conversation turns
25
+ * - "progress" / "thinking" — intermediate agent state
26
+ * - "last-prompt" / "file-history-snapshot" — metadata (not conversation state)
27
+ */
28
+ export interface SessionEntry {
29
+ type?: string;
30
+ timestamp?: string;
31
+ cwd?: string;
32
+ message?: {
33
+ content?: string | ContentBlock[];
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Parsed session state extracted from a JSONL file.
39
+ * Aggregates data from all entries into a single summary.
40
+ */
41
+ export interface ClaudeSession {
42
+ sessionId: string;
43
+ projectPath: string;
44
+ lastCwd?: string;
45
+ sessionStart: Date;
46
+ lastActive: Date;
47
+ lastEntryType?: string;
48
+ isInterrupted: boolean;
49
+ lastUserMessage?: string;
50
+ /** First meaningful user prompt in the session (post noise filter) */
51
+ firstUserMessage?: string;
52
+ }
53
+
54
+ /** Entry types that are metadata, not conversation state. */
55
+ const METADATA_ENTRY_TYPES = new Set(['last-prompt', 'file-history-snapshot']);
56
+
57
+ /**
58
+ * Parses Claude Code session JSONL files into structured data.
59
+ *
60
+ * Session files live at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
61
+ * and contain one JSON object per line, each representing a conversation
62
+ * event (user turn, assistant response, tool call, etc.).
63
+ */
64
+ export class ClaudeSessionParser {
65
+ /**
66
+ * Parse a session JSONL file into a ClaudeSession summary.
67
+ *
68
+ * Iterates all lines to extract: session start time (from first entry),
69
+ * last activity timestamp, last entry type (for status), whether the
70
+ * session was interrupted, and the last meaningful user message.
71
+ *
72
+ * Returns null if the file is unreadable or empty.
73
+ */
74
+ readSession(filePath: string, projectPath: string): ClaudeSession | null {
75
+ const sessionId = path.basename(filePath, '.jsonl');
76
+
77
+ let content: string;
78
+ try {
79
+ content = fs.readFileSync(filePath, 'utf-8');
80
+ } catch {
81
+ return null;
82
+ }
83
+
84
+ const allLines = content.trim().split('\n');
85
+ if (allLines.length === 0) {
86
+ return null;
87
+ }
88
+
89
+ const sessionStart = this.parseSessionStart(allLines[0]);
90
+
91
+ let lastEntryType: string | undefined;
92
+ let lastActive: Date | undefined;
93
+ let lastCwd: string | undefined;
94
+ let isInterrupted = false;
95
+ let lastUserMessage: string | undefined;
96
+ let firstUserMessage: string | undefined;
97
+
98
+ for (const line of allLines) {
99
+ try {
100
+ const entry: SessionEntry = JSON.parse(line);
101
+
102
+ if (entry.timestamp) {
103
+ const ts = new Date(entry.timestamp);
104
+ if (!Number.isNaN(ts.getTime())) {
105
+ lastActive = ts;
106
+ }
107
+ }
108
+
109
+ if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) {
110
+ lastCwd = entry.cwd;
111
+ }
112
+
113
+ if (entry.type && !METADATA_ENTRY_TYPES.has(entry.type)) {
114
+ lastEntryType = entry.type;
115
+
116
+ if (entry.type === 'user') {
117
+ const msgContent = entry.message?.content;
118
+ isInterrupted =
119
+ Array.isArray(msgContent) &&
120
+ msgContent.some(
121
+ (c) =>
122
+ (c.type === 'text' &&
123
+ c.text?.includes('[Request interrupted')) ||
124
+ (c.type === 'tool_result' &&
125
+ c.content?.includes('[Request interrupted')),
126
+ );
127
+
128
+ const text = this.extractUserMessageText(msgContent);
129
+ if (text) {
130
+ lastUserMessage = text;
131
+ if (!firstUserMessage) {
132
+ firstUserMessage = text;
133
+ }
134
+ }
135
+ } else {
136
+ isInterrupted = false;
137
+ }
138
+ }
139
+ } catch {
140
+ continue;
141
+ }
142
+ }
143
+
144
+ return {
145
+ sessionId,
146
+ projectPath: projectPath || lastCwd || '',
147
+ lastCwd,
148
+ sessionStart: sessionStart || lastActive || new Date(),
149
+ lastActive: lastActive || new Date(),
150
+ lastEntryType,
151
+ isInterrupted,
152
+ lastUserMessage,
153
+ firstUserMessage,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Determine agent status from parsed session state.
159
+ *
160
+ * Status mapping:
161
+ * - "user" + interrupted → WAITING (agent finished, awaiting new input)
162
+ * - "user" + not interrupted → RUNNING (agent is processing)
163
+ * - "progress" / "thinking" → RUNNING
164
+ * - "assistant" → WAITING (agent responded, awaiting user)
165
+ * - "system" → IDLE
166
+ */
167
+ determineStatus(session: ClaudeSession): AgentStatus {
168
+ if (!session.lastEntryType) {
169
+ return AgentStatus.UNKNOWN;
170
+ }
171
+
172
+ if (session.lastEntryType === 'user') {
173
+ return session.isInterrupted
174
+ ? AgentStatus.WAITING
175
+ : AgentStatus.RUNNING;
176
+ }
177
+
178
+ if (
179
+ session.lastEntryType === 'progress' ||
180
+ session.lastEntryType === 'thinking'
181
+ ) {
182
+ return AgentStatus.RUNNING;
183
+ }
184
+
185
+ if (session.lastEntryType === 'assistant') {
186
+ return AgentStatus.WAITING;
187
+ }
188
+
189
+ if (session.lastEntryType === 'system') {
190
+ return AgentStatus.IDLE;
191
+ }
192
+
193
+ return AgentStatus.UNKNOWN;
194
+ }
195
+
196
+ /**
197
+ * Read the full conversation from a session JSONL file.
198
+ *
199
+ * Default mode returns only text content from user/assistant/system messages.
200
+ * Verbose mode also includes tool_use and tool_result blocks.
201
+ */
202
+ getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
203
+ const verbose = options?.verbose ?? false;
204
+
205
+ let content: string;
206
+ try {
207
+ content = fs.readFileSync(sessionFilePath, 'utf-8');
208
+ } catch {
209
+ return [];
210
+ }
211
+
212
+ const lines = content.trim().split('\n');
213
+ const messages: ConversationMessage[] = [];
214
+
215
+ for (const line of lines) {
216
+ let entry: SessionEntry;
217
+ try {
218
+ entry = JSON.parse(line);
219
+ } catch {
220
+ continue;
221
+ }
222
+
223
+ const entryType = entry.type;
224
+ if (!entryType || METADATA_ENTRY_TYPES.has(entryType)) continue;
225
+ if (entryType === 'progress' || entryType === 'thinking') continue;
226
+
227
+ let role: ConversationMessage['role'];
228
+ if (entryType === 'user') {
229
+ role = 'user';
230
+ } else if (entryType === 'assistant') {
231
+ role = 'assistant';
232
+ } else if (entryType === 'system') {
233
+ role = 'system';
234
+ } else {
235
+ continue;
236
+ }
237
+
238
+ const text = this.extractConversationContent(entry.message?.content, role, verbose);
239
+ if (!text) continue;
240
+
241
+ messages.push({
242
+ role,
243
+ content: text,
244
+ timestamp: entry.timestamp,
245
+ });
246
+ }
247
+
248
+ return messages;
249
+ }
250
+
251
+ /**
252
+ * Parse session start time from the first JSONL line.
253
+ *
254
+ * Claude Code may emit a "file-history-snapshot" as the first entry,
255
+ * which stores its timestamp inside "snapshot.timestamp" rather than
256
+ * at the root level.
257
+ */
258
+ private parseSessionStart(firstLine: string): Date | null {
259
+ try {
260
+ const firstEntry = JSON.parse(firstLine);
261
+ const rawTs: string | undefined =
262
+ firstEntry.timestamp || firstEntry.snapshot?.timestamp;
263
+ if (rawTs) {
264
+ const ts = new Date(rawTs);
265
+ if (!Number.isNaN(ts.getTime())) {
266
+ return ts;
267
+ }
268
+ }
269
+ } catch {
270
+ /* malformed first line */
271
+ }
272
+ return null;
273
+ }
274
+
275
+ /**
276
+ * Extract meaningful text from a user message content field.
277
+ *
278
+ * Handles multiple formats:
279
+ * - Plain string content
280
+ * - Array of content blocks (extracts first text block)
281
+ * - Skill slash-commands (<command-message> tags)
282
+ * - Expanded skill content (extracts ARGUMENTS line)
283
+ * - Filters noise messages (interruptions, tool loaded, session continued)
284
+ */
285
+ private extractUserMessageText(
286
+ content: string | Array<{ type?: string; text?: string }> | undefined,
287
+ ): string | undefined {
288
+ if (!content) {
289
+ return undefined;
290
+ }
291
+
292
+ let raw: string | undefined;
293
+
294
+ if (typeof content === 'string') {
295
+ raw = content.trim();
296
+ } else if (Array.isArray(content)) {
297
+ for (const block of content) {
298
+ if (block.type === 'text' && block.text?.trim()) {
299
+ raw = block.text.trim();
300
+ break;
301
+ }
302
+ }
303
+ }
304
+
305
+ if (!raw) {
306
+ return undefined;
307
+ }
308
+
309
+ if (raw.startsWith('<command-message>')) {
310
+ return this.parseCommandMessage(raw);
311
+ }
312
+
313
+ if (raw.startsWith('Base directory for this skill:')) {
314
+ const argsMatch = raw.match(/\nARGUMENTS:\s*(.+)/);
315
+ return argsMatch?.[1]?.trim() || undefined;
316
+ }
317
+
318
+ if (isNoiseMessage(raw)) {
319
+ return undefined;
320
+ }
321
+
322
+ return raw;
323
+ }
324
+
325
+ /**
326
+ * Parse a <command-message> string into "/command args" format.
327
+ */
328
+ private parseCommandMessage(raw: string): string | undefined {
329
+ const nameMatch = raw.match(/<command-name>([^<]+)<\/command-name>/);
330
+ const argsMatch = raw.match(/<command-args>([^<]+)<\/command-args>/);
331
+ const name = nameMatch?.[1]?.trim();
332
+ if (!name) {
333
+ return undefined;
334
+ }
335
+ const args = argsMatch?.[1]?.trim();
336
+ return args ? `${name} ${args}` : name;
337
+ }
338
+
339
+ /**
340
+ * Extract displayable content from a message content field for conversation output.
341
+ */
342
+ private extractConversationContent(
343
+ content: string | ContentBlock[] | undefined,
344
+ role: ConversationMessage['role'],
345
+ verbose: boolean,
346
+ ): string | undefined {
347
+ if (!content) return undefined;
348
+
349
+ if (typeof content === 'string') {
350
+ const cleaned = stripHarnessTags(content);
351
+ if (role === 'user' && isNoiseMessage(cleaned)) return undefined;
352
+ return cleaned || undefined;
353
+ }
354
+
355
+ if (!Array.isArray(content)) return undefined;
356
+
357
+ const parts: string[] = [];
358
+
359
+ for (const block of content) {
360
+ if (block.type === 'text' && block.text?.trim()) {
361
+ const cleaned = stripHarnessTags(block.text);
362
+ if (!cleaned) continue;
363
+ if (role === 'user' && isNoiseMessage(cleaned)) continue;
364
+ parts.push(cleaned);
365
+ } else if (block.type === 'tool_use' && verbose) {
366
+ const inputSummary = block.input?.file_path || block.input?.pattern || block.input?.command || '';
367
+ parts.push(`[Tool: ${block.name}]${inputSummary ? ' ' + inputSummary : ''}`);
368
+ } else if (block.type === 'tool_result' && verbose) {
369
+ const truncated = truncateToolResult(block.content || '');
370
+ const prefix = block.is_error ? '[Tool Error]' : '[Tool Result]';
371
+ parts.push(`${prefix} ${truncated}`);
372
+ }
373
+ }
374
+
375
+ return parts.length > 0 ? parts.join('\n') : undefined;
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Tags whose entire block (including content) should be dropped — they are
381
+ * harness-injected prompt context (system reminders, hook output, command
382
+ * stdout), not meaningful conversation content.
383
+ */
384
+ const HARNESS_DROP_TAGS = [
385
+ 'system-reminder',
386
+ 'local-command-stdout',
387
+ 'local-command-stderr',
388
+ 'user-prompt-submit-hook',
389
+ 'command-stdout',
390
+ 'command-stderr',
391
+ 'bash-input',
392
+ 'bash-stdout',
393
+ 'bash-stderr',
394
+ 'command-message',
395
+ ] as const;
396
+
397
+ const HARNESS_DROP_RE = new RegExp(
398
+ `<(${HARNESS_DROP_TAGS.join('|')})>[\\s\\S]*?</\\1>`,
399
+ 'g',
400
+ );
401
+
402
+ const COMMAND_INVOCATION_RE =
403
+ /<command-name>([^<]+)<\/command-name>(?:\s*<command-args>([\s\S]*?)<\/command-args>)?/g;
404
+
405
+ /**
406
+ * Remove harness-injected XML blocks from message text and collapse
407
+ * <command-name>/<command-args> pairs into a "/name args" shorthand.
408
+ *
409
+ * Returns the cleaned, trimmed text. Returns an empty string if nothing
410
+ * survives stripping.
411
+ */
412
+ function stripHarnessTags(text: string): string {
413
+ let out = text.replace(HARNESS_DROP_RE, '');
414
+
415
+ out = out.replace(COMMAND_INVOCATION_RE, (_match, rawName: string, rawArgs?: string) => {
416
+ const name = rawName.trim();
417
+ const args = rawArgs?.trim();
418
+ return args ? `${name} ${args}` : name;
419
+ });
420
+
421
+ return out.replace(/\n{3,}/g, '\n\n').trim();
422
+ }
423
+
424
+ /** Check if a message is noise (not a meaningful user intent). */
425
+ function isNoiseMessage(text: string): boolean {
426
+ return (
427
+ text.startsWith('[Request interrupted') ||
428
+ text === 'Tool loaded.' ||
429
+ text.startsWith('This session is being continued')
430
+ );
431
+ }
432
+
433
+ function truncateToolResult(content: string, maxLength = 200): string {
434
+ const firstLine = content.split('\n')[0] || '';
435
+ if (firstLine.length <= maxLength) return firstLine;
436
+ return firstLine.slice(0, maxLength - 3) + '...';
437
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Escape a string for safe use inside an AppleScript double-quoted string.
3
+ * Backslashes, double quotes, and newlines must be escaped.
4
+ */
5
+ export function escapeAppleScript(text: string): string {
6
+ return text
7
+ .replace(/\\/g, '\\\\')
8
+ .replace(/"/g, '\\"')
9
+ .replace(/\r\n|\r|\n/g, '\\n');
10
+ }
@@ -2,19 +2,18 @@
2
2
  * Process Detection Utilities
3
3
  *
4
4
  * Shared shell command wrappers for detecting and inspecting running processes.
5
- * All execSync calls for process data live here — adapters must not call execSync directly.
5
+ * All execFileSync calls for process data live here — adapters must not call execFileSync directly.
6
6
  */
7
7
 
8
8
  import * as path from 'path';
9
- import { execSync } from 'child_process';
9
+ import { execFileSync } from 'child_process';
10
10
  import type { ProcessInfo } from '../adapters/AgentAdapter';
11
11
 
12
12
  /**
13
13
  * List running processes matching an agent executable name.
14
14
  *
15
- * Uses `ps aux | grep <pattern>` at shell level for performance, then post-filters
16
- * by checking that the executable basename matches exactly (avoids matching
17
- * `claude-helper`, `vscode-claude-extension`, or the grep process itself).
15
+ * Uses `ps aux` then filters in JS for exact executable basename match.
16
+ * This avoids shell pipelines and string interpolation.
18
17
  *
19
18
  * Returned ProcessInfo has pid, command, tty populated.
20
19
  * cwd and startTime are NOT populated — call enrichProcesses() to fill them.
@@ -26,14 +25,9 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] {
26
25
  }
27
26
 
28
27
  try {
29
- // Use [c]laude trick to avoid matching the grep process itself
30
- const escapedPattern = `[${namePattern[0]}]${namePattern.slice(1)}`;
31
-
32
- const output = execSync(
33
- `ps aux | grep -i '${escapedPattern}'`,
34
- { encoding: 'utf-8' },
35
- );
28
+ const output = execFileSync('ps', ['aux'], { encoding: 'utf-8' });
36
29
 
30
+ const lowerPattern = namePattern.toLowerCase();
37
31
  const processes: ProcessInfo[] = [];
38
32
 
39
33
  for (const line of output.trim().split('\n')) {
@@ -48,10 +42,10 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] {
48
42
  const tty = parts[6];
49
43
  const command = parts.slice(10).join(' ');
50
44
 
51
- // Post-filter: check that the executable basename matches exactly
45
+ // Check that the executable basename matches exactly
52
46
  const executable = command.trim().split(/\s+/)[0] || '';
53
47
  const base = path.basename(executable).toLowerCase();
54
- if (base !== namePattern.toLowerCase() && base !== `${namePattern.toLowerCase()}.exe`) {
48
+ if (base !== lowerPattern && base !== `${lowerPattern}.exe`) {
55
49
  continue;
56
50
  }
57
51
 
@@ -82,9 +76,9 @@ export function batchGetProcessCwds(pids: number[]): Map<number, string> {
82
76
  if (pids.length === 0) return result;
83
77
 
84
78
  try {
85
- const output = execSync(
86
- `lsof -a -d cwd -Fn -p ${pids.join(',')} 2>/dev/null`,
87
- { encoding: 'utf-8' },
79
+ const output = execFileSync(
80
+ 'lsof', ['-a', '-d', 'cwd', '-Fn', '-p', pids.join(',')],
81
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
88
82
  );
89
83
 
90
84
  // lsof output format: p{PID}\nn{path}\np{PID}\nn{path}...
@@ -101,7 +95,10 @@ export function batchGetProcessCwds(pids: number[]): Map<number, string> {
101
95
  // Try per-PID fallback with pwdx (Linux)
102
96
  for (const pid of pids) {
103
97
  try {
104
- const output = execSync(`pwdx ${pid} 2>/dev/null`, { encoding: 'utf-8' });
98
+ const output = execFileSync(
99
+ 'pwdx', [String(pid)],
100
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
101
+ );
105
102
  const match = output.match(/^\d+:\s*(.+)$/);
106
103
  if (match) {
107
104
  result.set(pid, match[1].trim());
@@ -127,8 +124,8 @@ export function batchGetProcessStartTimes(pids: number[]): Map<number, Date> {
127
124
  if (pids.length === 0) return result;
128
125
 
129
126
  try {
130
- const output = execSync(
131
- `ps -o pid=,lstart= -p ${pids.join(',')}`,
127
+ const output = execFileSync(
128
+ 'ps', ['-o', 'pid=,lstart=', '-p', pids.join(',')],
132
129
  { encoding: 'utf-8' },
133
130
  );
134
131
 
@@ -185,9 +182,10 @@ export function enrichProcesses(processes: ProcessInfo[]): ProcessInfo[] {
185
182
  */
186
183
  export function getProcessTty(pid: number): string {
187
184
  try {
188
- const output = execSync(`ps -p ${pid} -o tty=`, {
189
- encoding: 'utf-8',
190
- });
185
+ const output = execFileSync(
186
+ 'ps', ['-p', String(pid), '-o', 'tty='],
187
+ { encoding: 'utf-8' },
188
+ );
191
189
 
192
190
  const tty = output.trim();
193
191
  return tty.startsWith('/dev/') ? tty.slice(5) : tty;
@@ -195,4 +193,3 @@ export function getProcessTty(pid: number): string {
195
193
  return '?';
196
194
  }
197
195
  }
198
-