@ai-devkit/agent-manager 0.18.0 → 0.19.1

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 (45) hide show
  1. package/dist/__tests__/adapters/CopilotAdapter.test.js +127 -2
  2. package/dist/__tests__/adapters/CopilotAdapter.test.js.map +1 -1
  3. package/dist/__tests__/adapters/GeminiCliAdapter.test.js +144 -2
  4. package/dist/__tests__/adapters/GeminiCliAdapter.test.js.map +1 -1
  5. package/dist/__tests__/terminal/TerminalFocusManager.test.js +73 -0
  6. package/dist/__tests__/terminal/TerminalFocusManager.test.js.map +1 -0
  7. package/dist/__tests__/utils/agents.test.js +17 -0
  8. package/dist/__tests__/utils/agents.test.js.map +1 -0
  9. package/dist/__tests__/utils/process.test.js +54 -5
  10. package/dist/__tests__/utils/process.test.js.map +1 -1
  11. package/dist/adapters/AgentAdapter.d.ts +2 -0
  12. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  13. package/dist/adapters/AgentAdapter.js.map +1 -1
  14. package/dist/adapters/CopilotAdapter.d.ts +4 -2
  15. package/dist/adapters/CopilotAdapter.d.ts.map +1 -1
  16. package/dist/adapters/CopilotAdapter.js +23 -12
  17. package/dist/adapters/CopilotAdapter.js.map +1 -1
  18. package/dist/adapters/GeminiCliAdapter.d.ts +1 -0
  19. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  20. package/dist/adapters/GeminiCliAdapter.js +36 -11
  21. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  22. package/dist/terminal/TerminalFocusManager.d.ts +1 -0
  23. package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
  24. package/dist/terminal/TerminalFocusManager.js +10 -9
  25. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  26. package/dist/utils/agents.d.ts +1 -1
  27. package/dist/utils/agents.d.ts.map +1 -1
  28. package/dist/utils/agents.js +29 -3
  29. package/dist/utils/agents.js.map +1 -1
  30. package/dist/utils/process.d.ts +11 -2
  31. package/dist/utils/process.d.ts.map +1 -1
  32. package/dist/utils/process.js +44 -9
  33. package/dist/utils/process.js.map +1 -1
  34. package/package.json +6 -1
  35. package/src/__tests__/adapters/CopilotAdapter.test.ts +97 -5
  36. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +139 -4
  37. package/src/__tests__/terminal/TerminalFocusManager.test.ts +92 -0
  38. package/src/__tests__/utils/agents.test.ts +17 -0
  39. package/src/__tests__/utils/process.test.ts +31 -7
  40. package/src/adapters/AgentAdapter.ts +3 -0
  41. package/src/adapters/CopilotAdapter.ts +25 -14
  42. package/src/adapters/GeminiCliAdapter.ts +42 -11
  43. package/src/terminal/TerminalFocusManager.ts +11 -3
  44. package/src/utils/agents.ts +22 -2
  45. package/src/utils/process.ts +64 -9
@@ -22,11 +22,11 @@ import type {
22
22
  ListSessionsOptions,
23
23
  } from './AgentAdapter.js';
24
24
  import { AgentStatus } from './AgentAdapter.js';
25
- import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
25
+ import { listAgentProcesses, enrichProcesses, findWrapperProcess, findWrapperProcessPids } from '../utils/process.js';
26
26
  import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js';
27
27
  import type { SessionFile } from '../utils/session.js';
28
28
  import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js';
29
- import { AgentRegistry } from '../utils/AgentRegistry.js';
29
+ import { AgentRegistry, type RegistryEntry } from '../utils/AgentRegistry.js';
30
30
 
31
31
  /**
32
32
  * A single Gemini CLI message content part. Mirrors the `{text?: string}`
@@ -117,40 +117,70 @@ export class GeminiCliAdapter implements AgentAdapter {
117
117
  const processes = nodeProcesses.filter((proc) => this.isGeminiExecutable(proc.command));
118
118
  if (processes.length === 0) return [];
119
119
 
120
- const { cachedAgents, remaining } = this.tryRegistryCache(processes);
120
+ const wrapperPids = findWrapperProcessPids(processes);
121
+ const { cachedAgents, remaining } = this.tryRegistryCache(processes, wrapperPids);
121
122
  if (remaining.length === 0) return this.deduplicateSessionAgents(cachedAgents);
122
123
 
123
- const { sessions, contentCache } = this.discoverSessions(remaining);
124
+ const candidateProcesses = remaining.filter((proc) => !wrapperPids.has(proc.pid));
125
+ const registryEntriesByPid = new Map(this.registry.list().map((entry) => [entry.pid, entry]));
126
+
127
+ const { sessions, contentCache } = this.discoverSessions(candidateProcesses);
124
128
  if (sessions.length === 0) {
129
+ const processOnlyAgents = candidateProcesses.map((proc) => {
130
+ const agent = this.mapProcessOnlyAgent(proc);
131
+ this.applyWrapperRegistryName(agent, proc, processes, registryEntriesByPid);
132
+ return agent;
133
+ });
134
+
125
135
  return this.deduplicateSessionAgents([
126
136
  ...cachedAgents,
127
- ...remaining.map((p) => this.mapProcessOnlyAgent(p)),
137
+ ...processOnlyAgents,
128
138
  ]);
129
139
  }
130
140
 
131
- const matches = matchProcessesToSessions(remaining, sessions);
141
+ const matches = matchProcessesToSessions(candidateProcesses, sessions);
132
142
  const matchedPids = new Set(matches.map((m) => m.process.pid));
143
+ const matchedProcesses: ProcessInfo[] = [];
133
144
  const agents: AgentInfo[] = [];
134
145
 
135
146
  for (const match of matches) {
136
147
  const cachedContent = contentCache.get(match.session.filePath);
137
148
  const sessionData = this.parseSession(cachedContent, match.session.filePath);
138
149
  if (sessionData) {
139
- agents.push(this.mapSessionToAgent(sessionData, match.process, match.session.filePath));
150
+ const agent = this.mapSessionToAgent(sessionData, match.process, match.session.filePath);
151
+ this.applyWrapperRegistryName(agent, match.process, processes, registryEntriesByPid);
152
+ agents.push(agent);
153
+ matchedProcesses.push(match.process);
140
154
  } else {
141
155
  matchedPids.delete(match.process.pid);
142
156
  }
143
157
  }
144
158
 
145
- for (const proc of remaining) {
146
- if (!matchedPids.has(proc.pid)) {
147
- agents.push(this.mapProcessOnlyAgent(proc));
159
+ const matchedWrapperPids = findWrapperProcessPids(candidateProcesses, matchedProcesses);
160
+ for (const proc of candidateProcesses) {
161
+ if (!matchedPids.has(proc.pid) && !matchedWrapperPids.has(proc.pid)) {
162
+ const agent = this.mapProcessOnlyAgent(proc);
163
+ this.applyWrapperRegistryName(agent, proc, processes, registryEntriesByPid);
164
+ agents.push(agent);
148
165
  }
149
166
  }
150
167
 
151
168
  return this.deduplicateSessionAgents([...cachedAgents, ...agents]);
152
169
  }
153
170
 
171
+ private applyWrapperRegistryName(
172
+ agent: AgentInfo,
173
+ processInfo: ProcessInfo,
174
+ processes: ProcessInfo[],
175
+ registryEntriesByPid: Map<number, RegistryEntry>,
176
+ ): void {
177
+ const wrapper = findWrapperProcess(processes, processInfo);
178
+ const wrapperEntry = wrapper ? registryEntriesByPid.get(wrapper.pid) : undefined;
179
+ if (wrapperEntry?.type === this.type) {
180
+ agent.name = wrapperEntry.name;
181
+ }
182
+ }
183
+
154
184
  private deduplicateSessionAgents(agents: AgentInfo[]): AgentInfo[] {
155
185
  const bySession = new Map<string, AgentInfo>();
156
186
  const result: AgentInfo[] = [];
@@ -187,7 +217,7 @@ export class GeminiCliAdapter implements AgentAdapter {
187
217
  return null;
188
218
  }
189
219
 
190
- private tryRegistryCache(processes: ProcessInfo[]): {
220
+ private tryRegistryCache(processes: ProcessInfo[], wrapperPids: Set<number>): {
191
221
  cachedAgents: AgentInfo[];
192
222
  remaining: ProcessInfo[];
193
223
  } {
@@ -198,6 +228,7 @@ export class GeminiCliAdapter implements AgentAdapter {
198
228
  for (const proc of processes) {
199
229
  const entry = byPid.get(proc.pid);
200
230
  if (
231
+ wrapperPids.has(proc.pid) ||
201
232
  !entry ||
202
233
  entry.type !== this.type ||
203
234
  !entry.sessionFilePath ||
@@ -98,8 +98,8 @@ export class TerminalFocusManager {
98
98
 
99
99
  private async findITerm2Session(tty: string): Promise<TerminalLocation | null> {
100
100
  try {
101
- // Check if iTerm2 is running first to avoid launching it
102
- await execFileAsync('pgrep', ['-x', 'iTerm2']);
101
+ // Check if iTerm2 is running first to avoid launching it.
102
+ if (!await this.isProcessRunning('iTerm2')) return null;
103
103
  } catch {
104
104
  return null;
105
105
  }
@@ -137,7 +137,7 @@ export class TerminalFocusManager {
137
137
  private async findTerminalAppWindow(tty: string): Promise<TerminalLocation | null> {
138
138
  try {
139
139
  // Check if Terminal.app is running
140
- await execFileAsync('pgrep', ['-x', 'Terminal']);
140
+ if (!await this.isProcessRunning('Terminal')) return null;
141
141
  } catch {
142
142
  return null;
143
143
  }
@@ -170,6 +170,14 @@ export class TerminalFocusManager {
170
170
  return null;
171
171
  }
172
172
 
173
+ private async isProcessRunning(name: string): Promise<boolean> {
174
+ const { stdout } = await execFileAsync('ps', ['-Axo', 'comm']);
175
+ return stdout
176
+ .split('\n')
177
+ .map((line) => line.trim())
178
+ .some((command) => command === name || command.endsWith(`/${name}`));
179
+ }
180
+
173
181
  private async focusTmuxPane(identifier: string): Promise<boolean> {
174
182
  try {
175
183
  await execFileAsync('tmux', ['switch-client', '-t', identifier]);
@@ -1,7 +1,7 @@
1
1
  import path from 'path';
2
2
  import type { AgentType } from '../adapters/AgentAdapter.js';
3
3
 
4
- export type StartableAgentType = Extract<AgentType, 'claude' | 'codex' | 'gemini_cli' | 'opencode'>;
4
+ export type StartableAgentType = Extract<AgentType, 'claude' | 'codex' | 'copilot' | 'gemini_cli' | 'opencode' | 'pi'>;
5
5
 
6
6
  export interface AgentConfig {
7
7
  /** Shell command to launch the agent (sent to tmux via `send-keys`). */
@@ -18,8 +18,10 @@ export interface AgentConfig {
18
18
  export const AGENTS: Record<StartableAgentType, AgentConfig> = {
19
19
  claude: { command: 'claude', matches: matchArgv0('claude') },
20
20
  codex: { command: 'codex', matches: matchArgv0('codex') },
21
- opencode: { command: 'opencode', matches: matchArgv0('opencode') },
21
+ copilot: { command: 'copilot', matches: matchArgv0Name('copilot-cli') },
22
22
  gemini_cli: { command: 'gemini', matches: matchAnyToken('gemini') },
23
+ opencode: { command: 'opencode', matches: matchArgv0('opencode') },
24
+ pi: { command: 'pi', matches: matchAnyBasename(['pi']) },
23
25
  };
24
26
 
25
27
  function matchArgv0(name: string): (psCommand: string) => boolean {
@@ -30,6 +32,14 @@ function matchArgv0(name: string): (psCommand: string) => boolean {
30
32
  };
31
33
  }
32
34
 
35
+ function matchArgv0Name(name: string): (psCommand: string) => boolean {
36
+ const lower = name.toLowerCase();
37
+ return (psCommand) => {
38
+ const token = psCommand.trim().split(/\s+/)[0];
39
+ return token ? token.toLowerCase().includes(lower) : false;
40
+ };
41
+ }
42
+
33
43
  function matchAnyToken(name: string): (psCommand: string) => boolean {
34
44
  const lower = name.toLowerCase();
35
45
  return (psCommand) => {
@@ -39,3 +49,13 @@ function matchAnyToken(name: string): (psCommand: string) => boolean {
39
49
  return false;
40
50
  };
41
51
  }
52
+
53
+ function matchAnyBasename(names: string[]): (psCommand: string) => boolean {
54
+ const lowers = new Set(names.map((name) => name.toLowerCase()));
55
+ return (psCommand) => {
56
+ for (const token of psCommand.trim().split(/\s+/)) {
57
+ if (lowers.has(path.basename(token).toLowerCase())) return true;
58
+ }
59
+ return false;
60
+ };
61
+ }
@@ -12,10 +12,10 @@ import type { ProcessInfo } from '../adapters/AgentAdapter.js';
12
12
  /**
13
13
  * List running processes matching an agent executable name.
14
14
  *
15
- * Uses `ps aux` then filters in JS for exact executable basename match.
15
+ * Uses `ps -axo` then filters in JS for exact executable basename match.
16
16
  * This avoids shell pipelines and string interpolation.
17
17
  *
18
- * Returned ProcessInfo has pid, command, tty populated.
18
+ * Returned ProcessInfo has pid, ppid, command, tty populated.
19
19
  * cwd and startTime are NOT populated — call enrichProcesses() to fill them.
20
20
  */
21
21
  export function listAgentProcesses(namePattern: string): ProcessInfo[] {
@@ -25,7 +25,7 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] {
25
25
  }
26
26
 
27
27
  try {
28
- const output = execFileSync('ps', ['aux'], { encoding: 'utf-8' });
28
+ const output = execFileSync('ps', ['-axo', 'pid=,ppid=,tty=,command='], { encoding: 'utf-8' });
29
29
 
30
30
  const lowerPattern = namePattern.toLowerCase();
31
31
  const processes: ProcessInfo[] = [];
@@ -33,14 +33,15 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] {
33
33
  for (const line of output.trim().split('\n')) {
34
34
  if (!line.trim()) continue;
35
35
 
36
- const parts = line.trim().split(/\s+/);
37
- if (parts.length < 11) continue;
36
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
37
+ if (!match) continue;
38
38
 
39
- const pid = parseInt(parts[1], 10);
40
- if (Number.isNaN(pid)) continue;
39
+ const pid = parseInt(match[1], 10);
40
+ const ppid = parseInt(match[2], 10);
41
+ if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
41
42
 
42
- const tty = parts[6];
43
- const command = parts.slice(10).join(' ');
43
+ const tty = match[3];
44
+ const command = match[4];
44
45
 
45
46
  // Check that the executable basename matches exactly
46
47
  const executable = command.trim().split(/\s+/)[0] || '';
@@ -53,6 +54,7 @@ export function listAgentProcesses(namePattern: string): ProcessInfo[] {
53
54
 
54
55
  processes.push({
55
56
  pid,
57
+ ppid,
56
58
  command,
57
59
  cwd: '',
58
60
  tty: ttyShort,
@@ -177,6 +179,59 @@ export function enrichProcesses(processes: ProcessInfo[]): ProcessInfo[] {
177
179
  return processes;
178
180
  }
179
181
 
182
+ function isSameTerminalProcess(proc: ProcessInfo, matched: ProcessInfo): boolean {
183
+ const sameTty = proc.tty !== '' && proc.tty !== '?' && proc.tty === matched.tty;
184
+ const sameCwd = proc.cwd !== '' && proc.cwd === matched.cwd;
185
+
186
+ return sameTty && (sameCwd || proc.cwd === '' || matched.cwd === '');
187
+ }
188
+
189
+ function matchesProcessIdentity(proc: ProcessInfo, matched: ProcessInfo): boolean {
190
+ return proc.pid === matched.pid || isSameTerminalProcess(proc, matched);
191
+ }
192
+
193
+ export function findWrapperProcess(
194
+ processes: ProcessInfo[],
195
+ child: ProcessInfo,
196
+ ): ProcessInfo | undefined {
197
+ return processes.find((proc) => (
198
+ proc.pid !== child.pid &&
199
+ child.ppid === proc.pid &&
200
+ matchesProcessIdentity(proc, child)
201
+ ));
202
+ }
203
+
204
+ /**
205
+ * Find parent wrapper processes that should not be reported as separate agents.
206
+ *
207
+ * A process is considered a wrapper when it is the parent of another candidate
208
+ * agent process in the same terminal/worktree, or when it points at the same
209
+ * terminal/worktree as an already session-matched process.
210
+ */
211
+ export function findWrapperProcessPids(
212
+ processes: ProcessInfo[],
213
+ matchedProcesses: ProcessInfo[] = [],
214
+ ): Set<number> {
215
+ const wrappers = new Set<number>();
216
+
217
+ for (const child of processes) {
218
+ const wrapper = findWrapperProcess(processes, child);
219
+ if (wrapper) {
220
+ wrappers.add(wrapper.pid);
221
+ }
222
+ }
223
+
224
+ for (const proc of processes) {
225
+ if (matchedProcesses.some((matched) => (
226
+ proc.pid !== matched.pid && isSameTerminalProcess(proc, matched)
227
+ ))) {
228
+ wrappers.add(proc.pid);
229
+ }
230
+ }
231
+
232
+ return wrappers;
233
+ }
234
+
180
235
  /**
181
236
  * Get the TTY device for a specific process
182
237
  */