@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.
- package/dist/AgentManager.d.ts +7 -0
- package/dist/AgentManager.d.ts.map +1 -1
- package/dist/AgentManager.js +9 -0
- package/dist/AgentManager.js.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +2 -44
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +10 -285
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
- package/dist/terminal/TerminalFocusManager.js +38 -27
- package/dist/terminal/TerminalFocusManager.js.map +1 -1
- package/dist/utils/ClaudeSessionParser.d.ts +112 -0
- package/dist/utils/ClaudeSessionParser.d.ts.map +1 -0
- package/dist/utils/ClaudeSessionParser.js +334 -0
- package/dist/utils/ClaudeSessionParser.js.map +1 -0
- package/dist/utils/process.d.ts +3 -4
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +11 -15
- package/dist/utils/process.js.map +1 -1
- package/package.json +1 -1
- package/src/AgentManager.ts +11 -1
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +29 -29
- package/src/__tests__/utils/process.test.ts +27 -27
- package/src/adapters/ClaudeCodeAdapter.ts +17 -365
- package/src/terminal/TerminalFocusManager.ts +38 -26
- package/src/utils/ClaudeSessionParser.ts +383 -0
- package/src/utils/process.ts +21 -24
|
@@ -0,0 +1,383 @@
|
|
|
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
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Entry types that are metadata, not conversation state. */
|
|
53
|
+
const METADATA_ENTRY_TYPES = new Set(['last-prompt', 'file-history-snapshot']);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parses Claude Code session JSONL files into structured data.
|
|
57
|
+
*
|
|
58
|
+
* Session files live at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
|
|
59
|
+
* and contain one JSON object per line, each representing a conversation
|
|
60
|
+
* event (user turn, assistant response, tool call, etc.).
|
|
61
|
+
*/
|
|
62
|
+
export class ClaudeSessionParser {
|
|
63
|
+
/**
|
|
64
|
+
* Parse a session JSONL file into a ClaudeSession summary.
|
|
65
|
+
*
|
|
66
|
+
* Iterates all lines to extract: session start time (from first entry),
|
|
67
|
+
* last activity timestamp, last entry type (for status), whether the
|
|
68
|
+
* session was interrupted, and the last meaningful user message.
|
|
69
|
+
*
|
|
70
|
+
* Returns null if the file is unreadable or empty.
|
|
71
|
+
*/
|
|
72
|
+
readSession(filePath: string, projectPath: string): ClaudeSession | null {
|
|
73
|
+
const sessionId = path.basename(filePath, '.jsonl');
|
|
74
|
+
|
|
75
|
+
let content: string;
|
|
76
|
+
try {
|
|
77
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const allLines = content.trim().split('\n');
|
|
83
|
+
if (allLines.length === 0) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sessionStart = this.parseSessionStart(allLines[0]);
|
|
88
|
+
|
|
89
|
+
let lastEntryType: string | undefined;
|
|
90
|
+
let lastActive: Date | undefined;
|
|
91
|
+
let lastCwd: string | undefined;
|
|
92
|
+
let isInterrupted = false;
|
|
93
|
+
let lastUserMessage: string | undefined;
|
|
94
|
+
|
|
95
|
+
for (const line of allLines) {
|
|
96
|
+
try {
|
|
97
|
+
const entry: SessionEntry = JSON.parse(line);
|
|
98
|
+
|
|
99
|
+
if (entry.timestamp) {
|
|
100
|
+
const ts = new Date(entry.timestamp);
|
|
101
|
+
if (!Number.isNaN(ts.getTime())) {
|
|
102
|
+
lastActive = ts;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) {
|
|
107
|
+
lastCwd = entry.cwd;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (entry.type && !METADATA_ENTRY_TYPES.has(entry.type)) {
|
|
111
|
+
lastEntryType = entry.type;
|
|
112
|
+
|
|
113
|
+
if (entry.type === 'user') {
|
|
114
|
+
const msgContent = entry.message?.content;
|
|
115
|
+
isInterrupted =
|
|
116
|
+
Array.isArray(msgContent) &&
|
|
117
|
+
msgContent.some(
|
|
118
|
+
(c) =>
|
|
119
|
+
(c.type === 'text' &&
|
|
120
|
+
c.text?.includes('[Request interrupted')) ||
|
|
121
|
+
(c.type === 'tool_result' &&
|
|
122
|
+
c.content?.includes('[Request interrupted')),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const text = this.extractUserMessageText(msgContent);
|
|
126
|
+
if (text) {
|
|
127
|
+
lastUserMessage = text;
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
isInterrupted = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
sessionId,
|
|
140
|
+
projectPath: projectPath || lastCwd || '',
|
|
141
|
+
lastCwd,
|
|
142
|
+
sessionStart: sessionStart || lastActive || new Date(),
|
|
143
|
+
lastActive: lastActive || new Date(),
|
|
144
|
+
lastEntryType,
|
|
145
|
+
isInterrupted,
|
|
146
|
+
lastUserMessage,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Determine agent status from parsed session state.
|
|
152
|
+
*
|
|
153
|
+
* Status mapping:
|
|
154
|
+
* - "user" + interrupted → WAITING (agent finished, awaiting new input)
|
|
155
|
+
* - "user" + not interrupted → RUNNING (agent is processing)
|
|
156
|
+
* - "progress" / "thinking" → RUNNING
|
|
157
|
+
* - "assistant" → WAITING (agent responded, awaiting user)
|
|
158
|
+
* - "system" → IDLE
|
|
159
|
+
*/
|
|
160
|
+
determineStatus(session: ClaudeSession): AgentStatus {
|
|
161
|
+
if (!session.lastEntryType) {
|
|
162
|
+
return AgentStatus.UNKNOWN;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (session.lastEntryType === 'user') {
|
|
166
|
+
return session.isInterrupted
|
|
167
|
+
? AgentStatus.WAITING
|
|
168
|
+
: AgentStatus.RUNNING;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
session.lastEntryType === 'progress' ||
|
|
173
|
+
session.lastEntryType === 'thinking'
|
|
174
|
+
) {
|
|
175
|
+
return AgentStatus.RUNNING;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (session.lastEntryType === 'assistant') {
|
|
179
|
+
return AgentStatus.WAITING;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (session.lastEntryType === 'system') {
|
|
183
|
+
return AgentStatus.IDLE;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return AgentStatus.UNKNOWN;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Read the full conversation from a session JSONL file.
|
|
191
|
+
*
|
|
192
|
+
* Default mode returns only text content from user/assistant/system messages.
|
|
193
|
+
* Verbose mode also includes tool_use and tool_result blocks.
|
|
194
|
+
*/
|
|
195
|
+
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
|
|
196
|
+
const verbose = options?.verbose ?? false;
|
|
197
|
+
|
|
198
|
+
let content: string;
|
|
199
|
+
try {
|
|
200
|
+
content = fs.readFileSync(sessionFilePath, 'utf-8');
|
|
201
|
+
} catch {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const lines = content.trim().split('\n');
|
|
206
|
+
const messages: ConversationMessage[] = [];
|
|
207
|
+
|
|
208
|
+
for (const line of lines) {
|
|
209
|
+
let entry: SessionEntry;
|
|
210
|
+
try {
|
|
211
|
+
entry = JSON.parse(line);
|
|
212
|
+
} catch {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const entryType = entry.type;
|
|
217
|
+
if (!entryType || METADATA_ENTRY_TYPES.has(entryType)) continue;
|
|
218
|
+
if (entryType === 'progress' || entryType === 'thinking') continue;
|
|
219
|
+
|
|
220
|
+
let role: ConversationMessage['role'];
|
|
221
|
+
if (entryType === 'user') {
|
|
222
|
+
role = 'user';
|
|
223
|
+
} else if (entryType === 'assistant') {
|
|
224
|
+
role = 'assistant';
|
|
225
|
+
} else if (entryType === 'system') {
|
|
226
|
+
role = 'system';
|
|
227
|
+
} else {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const text = this.extractConversationContent(entry.message?.content, role, verbose);
|
|
232
|
+
if (!text) continue;
|
|
233
|
+
|
|
234
|
+
messages.push({
|
|
235
|
+
role,
|
|
236
|
+
content: text,
|
|
237
|
+
timestamp: entry.timestamp,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return messages;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Parse session start time from the first JSONL line.
|
|
246
|
+
*
|
|
247
|
+
* Claude Code may emit a "file-history-snapshot" as the first entry,
|
|
248
|
+
* which stores its timestamp inside "snapshot.timestamp" rather than
|
|
249
|
+
* at the root level.
|
|
250
|
+
*/
|
|
251
|
+
private parseSessionStart(firstLine: string): Date | null {
|
|
252
|
+
try {
|
|
253
|
+
const firstEntry = JSON.parse(firstLine);
|
|
254
|
+
const rawTs: string | undefined =
|
|
255
|
+
firstEntry.timestamp || firstEntry.snapshot?.timestamp;
|
|
256
|
+
if (rawTs) {
|
|
257
|
+
const ts = new Date(rawTs);
|
|
258
|
+
if (!Number.isNaN(ts.getTime())) {
|
|
259
|
+
return ts;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
/* malformed first line */
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Extract meaningful text from a user message content field.
|
|
270
|
+
*
|
|
271
|
+
* Handles multiple formats:
|
|
272
|
+
* - Plain string content
|
|
273
|
+
* - Array of content blocks (extracts first text block)
|
|
274
|
+
* - Skill slash-commands (<command-message> tags)
|
|
275
|
+
* - Expanded skill content (extracts ARGUMENTS line)
|
|
276
|
+
* - Filters noise messages (interruptions, tool loaded, session continued)
|
|
277
|
+
*/
|
|
278
|
+
private extractUserMessageText(
|
|
279
|
+
content: string | Array<{ type?: string; text?: string }> | undefined,
|
|
280
|
+
): string | undefined {
|
|
281
|
+
if (!content) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let raw: string | undefined;
|
|
286
|
+
|
|
287
|
+
if (typeof content === 'string') {
|
|
288
|
+
raw = content.trim();
|
|
289
|
+
} else if (Array.isArray(content)) {
|
|
290
|
+
for (const block of content) {
|
|
291
|
+
if (block.type === 'text' && block.text?.trim()) {
|
|
292
|
+
raw = block.text.trim();
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!raw) {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (raw.startsWith('<command-message>')) {
|
|
303
|
+
return this.parseCommandMessage(raw);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (raw.startsWith('Base directory for this skill:')) {
|
|
307
|
+
const argsMatch = raw.match(/\nARGUMENTS:\s*(.+)/);
|
|
308
|
+
return argsMatch?.[1]?.trim() || undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (isNoiseMessage(raw)) {
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return raw;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Parse a <command-message> string into "/command args" format.
|
|
320
|
+
*/
|
|
321
|
+
private parseCommandMessage(raw: string): string | undefined {
|
|
322
|
+
const nameMatch = raw.match(/<command-name>([^<]+)<\/command-name>/);
|
|
323
|
+
const argsMatch = raw.match(/<command-args>([^<]+)<\/command-args>/);
|
|
324
|
+
const name = nameMatch?.[1]?.trim();
|
|
325
|
+
if (!name) {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
const args = argsMatch?.[1]?.trim();
|
|
329
|
+
return args ? `${name} ${args}` : name;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Extract displayable content from a message content field for conversation output.
|
|
334
|
+
*/
|
|
335
|
+
private extractConversationContent(
|
|
336
|
+
content: string | ContentBlock[] | undefined,
|
|
337
|
+
role: ConversationMessage['role'],
|
|
338
|
+
verbose: boolean,
|
|
339
|
+
): string | undefined {
|
|
340
|
+
if (!content) return undefined;
|
|
341
|
+
|
|
342
|
+
if (typeof content === 'string') {
|
|
343
|
+
const trimmed = content.trim();
|
|
344
|
+
if (role === 'user' && isNoiseMessage(trimmed)) return undefined;
|
|
345
|
+
return trimmed || undefined;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!Array.isArray(content)) return undefined;
|
|
349
|
+
|
|
350
|
+
const parts: string[] = [];
|
|
351
|
+
|
|
352
|
+
for (const block of content) {
|
|
353
|
+
if (block.type === 'text' && block.text?.trim()) {
|
|
354
|
+
if (role === 'user' && isNoiseMessage(block.text.trim())) continue;
|
|
355
|
+
parts.push(block.text.trim());
|
|
356
|
+
} else if (block.type === 'tool_use' && verbose) {
|
|
357
|
+
const inputSummary = block.input?.file_path || block.input?.pattern || block.input?.command || '';
|
|
358
|
+
parts.push(`[Tool: ${block.name}]${inputSummary ? ' ' + inputSummary : ''}`);
|
|
359
|
+
} else if (block.type === 'tool_result' && verbose) {
|
|
360
|
+
const truncated = truncateToolResult(block.content || '');
|
|
361
|
+
const prefix = block.is_error ? '[Tool Error]' : '[Tool Result]';
|
|
362
|
+
parts.push(`${prefix} ${truncated}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return parts.length > 0 ? parts.join('\n') : undefined;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Check if a message is noise (not a meaningful user intent). */
|
|
371
|
+
function isNoiseMessage(text: string): boolean {
|
|
372
|
+
return (
|
|
373
|
+
text.startsWith('[Request interrupted') ||
|
|
374
|
+
text === 'Tool loaded.' ||
|
|
375
|
+
text.startsWith('This session is being continued')
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function truncateToolResult(content: string, maxLength = 200): string {
|
|
380
|
+
const firstLine = content.split('\n')[0] || '';
|
|
381
|
+
if (firstLine.length <= maxLength) return firstLine;
|
|
382
|
+
return firstLine.slice(0, maxLength - 3) + '...';
|
|
383
|
+
}
|
package/src/utils/process.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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 !==
|
|
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 =
|
|
86
|
-
|
|
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 =
|
|
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 =
|
|
131
|
-
|
|
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 =
|
|
189
|
-
|
|
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
|
-
|