@ai-devkit/agent-manager 0.26.2 → 0.26.4

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 (104) hide show
  1. package/dist/AgentManager.d.ts +5 -2
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +28 -10
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/__tests__/AgentManager.test.js +289 -4
  6. package/dist/__tests__/AgentManager.test.js.map +1 -1
  7. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js +13 -4
  8. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js.map +1 -1
  9. package/dist/__tests__/adapters/CodexAdapter.test.js +13 -4
  10. package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -1
  11. package/dist/__tests__/adapters/CopilotAdapter.test.js +7 -2
  12. package/dist/__tests__/adapters/CopilotAdapter.test.js.map +1 -1
  13. package/dist/__tests__/adapters/GeminiCliAdapter.test.js +18 -5
  14. package/dist/__tests__/adapters/GeminiCliAdapter.test.js.map +1 -1
  15. package/dist/__tests__/adapters/GrokCliAdapter.test.js +7 -2
  16. package/dist/__tests__/adapters/GrokCliAdapter.test.js.map +1 -1
  17. package/dist/__tests__/adapters/OpenCodeAdapter.test.js +13 -4
  18. package/dist/__tests__/adapters/OpenCodeAdapter.test.js.map +1 -1
  19. package/dist/__tests__/adapters/PiAdapter.test.js +32 -4
  20. package/dist/__tests__/adapters/PiAdapter.test.js.map +1 -1
  21. package/dist/__tests__/utils/AgentRegistry.test.js +140 -0
  22. package/dist/__tests__/utils/AgentRegistry.test.js.map +1 -1
  23. package/dist/__tests__/utils/process.test.js +90 -2
  24. package/dist/__tests__/utils/process.test.js.map +1 -1
  25. package/dist/adapters/AgentAdapter.d.ts +9 -3
  26. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  27. package/dist/adapters/AgentAdapter.js.map +1 -1
  28. package/dist/adapters/ClaudeCodeAdapter.d.ts +5 -4
  29. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  30. package/dist/adapters/ClaudeCodeAdapter.js +11 -7
  31. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  32. package/dist/adapters/CodexAdapter.d.ts +5 -4
  33. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  34. package/dist/adapters/CodexAdapter.js +11 -7
  35. package/dist/adapters/CodexAdapter.js.map +1 -1
  36. package/dist/adapters/CopilotAdapter.d.ts +5 -4
  37. package/dist/adapters/CopilotAdapter.d.ts.map +1 -1
  38. package/dist/adapters/CopilotAdapter.js +11 -7
  39. package/dist/adapters/CopilotAdapter.js.map +1 -1
  40. package/dist/adapters/GeminiCliAdapter.d.ts +6 -5
  41. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  42. package/dist/adapters/GeminiCliAdapter.js +12 -8
  43. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  44. package/dist/adapters/GrokCliAdapter.d.ts +3 -2
  45. package/dist/adapters/GrokCliAdapter.d.ts.map +1 -1
  46. package/dist/adapters/GrokCliAdapter.js +10 -6
  47. package/dist/adapters/GrokCliAdapter.js.map +1 -1
  48. package/dist/adapters/OpenCodeAdapter.d.ts +5 -4
  49. package/dist/adapters/OpenCodeAdapter.d.ts.map +1 -1
  50. package/dist/adapters/OpenCodeAdapter.js +11 -7
  51. package/dist/adapters/OpenCodeAdapter.js.map +1 -1
  52. package/dist/adapters/PiAdapter.d.ts +3 -2
  53. package/dist/adapters/PiAdapter.d.ts.map +1 -1
  54. package/dist/adapters/PiAdapter.js +12 -9
  55. package/dist/adapters/PiAdapter.js.map +1 -1
  56. package/dist/adapters/index.d.ts +1 -1
  57. package/dist/adapters/index.d.ts.map +1 -1
  58. package/dist/adapters/index.js.map +1 -1
  59. package/dist/database/connection.d.ts +1 -1
  60. package/dist/database/connection.d.ts.map +1 -1
  61. package/dist/database/connection.js +1 -1
  62. package/dist/database/connection.js.map +1 -1
  63. package/dist/index.d.ts +3 -2
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +1 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/utils/AgentRegistry.d.ts +14 -1
  68. package/dist/utils/AgentRegistry.d.ts.map +1 -1
  69. package/dist/utils/AgentRegistry.js +65 -20
  70. package/dist/utils/AgentRegistry.js.map +1 -1
  71. package/dist/utils/index.d.ts +1 -1
  72. package/dist/utils/index.d.ts.map +1 -1
  73. package/dist/utils/index.js +1 -1
  74. package/dist/utils/index.js.map +1 -1
  75. package/dist/utils/process.d.ts +9 -1
  76. package/dist/utils/process.d.ts.map +1 -1
  77. package/dist/utils/process.js +155 -10
  78. package/dist/utils/process.js.map +1 -1
  79. package/package.json +1 -1
  80. package/src/AgentManager.ts +35 -8
  81. package/src/__tests__/AgentManager.test.ts +231 -4
  82. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +16 -5
  83. package/src/__tests__/adapters/CodexAdapter.test.ts +16 -5
  84. package/src/__tests__/adapters/CopilotAdapter.test.ts +8 -1
  85. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +20 -4
  86. package/src/__tests__/adapters/GrokCliAdapter.test.ts +8 -1
  87. package/src/__tests__/adapters/OpenCodeAdapter.test.ts +16 -5
  88. package/src/__tests__/adapters/PiAdapter.test.ts +28 -5
  89. package/src/__tests__/utils/AgentRegistry.test.ts +119 -0
  90. package/src/__tests__/utils/process.test.ts +72 -1
  91. package/src/adapters/AgentAdapter.ts +11 -3
  92. package/src/adapters/ClaudeCodeAdapter.ts +10 -7
  93. package/src/adapters/CodexAdapter.ts +10 -7
  94. package/src/adapters/CopilotAdapter.ts +16 -7
  95. package/src/adapters/GeminiCliAdapter.ts +17 -8
  96. package/src/adapters/GrokCliAdapter.ts +9 -6
  97. package/src/adapters/OpenCodeAdapter.ts +10 -7
  98. package/src/adapters/PiAdapter.ts +10 -9
  99. package/src/adapters/index.ts +1 -1
  100. package/src/database/connection.ts +4 -2
  101. package/src/index.ts +3 -1
  102. package/src/utils/AgentRegistry.ts +85 -17
  103. package/src/utils/index.ts +1 -1
  104. package/src/utils/process.ts +160 -10
@@ -59,7 +59,7 @@ export interface ProcessInfo {
59
59
  /** Process ID */
60
60
  pid: number;
61
61
 
62
- /** Parent process ID, populated by listAgentProcesses when available */
62
+ /** Parent process ID, populated by process discovery when available */
63
63
  ppid?: number;
64
64
 
65
65
  /** Process command */
@@ -71,7 +71,7 @@ export interface ProcessInfo {
71
71
  /** Terminal TTY (e.g., "ttys030") */
72
72
  tty: string;
73
73
 
74
- /** Process start time, populated by enrichProcesses */
74
+ /** Process start time, populated by process enrichment */
75
75
  startTime?: Date;
76
76
  }
77
77
 
@@ -148,6 +148,11 @@ export interface ListSessionsOptions {
148
148
  type?: AgentType;
149
149
  }
150
150
 
151
+ export interface AgentDetectionContext {
152
+ /** One enriched process snapshot shared across this manager refresh. */
153
+ readonly processes: readonly ProcessInfo[];
154
+ }
155
+
151
156
  /**
152
157
  * Agent Adapter Interface
153
158
  *
@@ -157,11 +162,14 @@ export interface AgentAdapter {
157
162
  /** Type of agent this adapter handles */
158
163
  readonly type: AgentType;
159
164
 
165
+ /** Executable basenames required for shared process discovery. */
166
+ readonly processNames?: readonly string[];
167
+
160
168
  /**
161
169
  * Detect running agents of this type
162
170
  * @returns List of detected agents
163
171
  */
164
- detectAgents(): Promise<AgentInfo[]>;
172
+ detectAgents(context?: AgentDetectionContext): Promise<AgentInfo[]>;
165
173
 
166
174
  /**
167
175
  * Check if this adapter can handle the given process
@@ -7,9 +7,10 @@ import type {
7
7
  ConversationMessage,
8
8
  SessionSummary,
9
9
  ListSessionsOptions,
10
+ AgentDetectionContext,
10
11
  } from './AgentAdapter.js';
11
12
  import { AgentStatus } from './AgentAdapter.js';
12
- import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
13
+ import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../utils/process.js';
13
14
  import { batchGetSessionFileBirthtimes, isDirectory, listJsonl, safeReaddir, safeStat } from '../utils/session.js';
14
15
  import type { SessionFile } from '../utils/session.js';
15
16
  import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js';
@@ -60,14 +61,15 @@ const PID_FILE_STALENESS_MS = 60000;
60
61
  * Claude Code Adapter
61
62
  *
62
63
  * Detects Claude Code agents by:
63
- * 1. Finding running claude processes via shared listAgentProcesses()
64
- * 2. Enriching with CWD and start times via shared enrichProcesses()
64
+ * 1. Filtering Claude processes from a shared asynchronous process snapshot
65
+ * 2. Using snapshot CWD and start-time enrichment
65
66
  * 3. Attempting authoritative PID-file matching via ~/.claude/sessions/<pid>.json
66
67
  * 4. Falling back to CWD+birthtime heuristic (matchProcessesToSessions) for processes without a PID file
67
68
  * 5. Extracting summary from last user message in session JSONL
68
69
  */
69
70
  export class ClaudeCodeAdapter implements AgentAdapter {
70
71
  readonly type = 'claude' as const;
72
+ readonly processNames = ['claude'] as const;
71
73
 
72
74
  private projectsDir: string;
73
75
  private sessionsDir: string;
@@ -85,13 +87,14 @@ export class ClaudeCodeAdapter implements AgentAdapter {
85
87
  }
86
88
 
87
89
  private isClaudeExecutable(command: string): boolean {
88
- const executable = command.trim().split(/\s+/)[0] || '';
89
- const base = path.basename(executable).toLowerCase();
90
+ const base = executableBasename(command);
90
91
  return base === 'claude' || base === 'claude.exe';
91
92
  }
92
93
 
93
- async detectAgents(): Promise<AgentInfo[]> {
94
- const processes = enrichProcesses(listAgentProcesses('claude'));
94
+ async detectAgents(context?: AgentDetectionContext): Promise<AgentInfo[]> {
95
+ const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames);
96
+ const relevant = filterByProcessNames(snapshot, this.processNames);
97
+ const processes = relevant.filter((process) => this.canHandle(process));
95
98
  if (processes.length === 0) {
96
99
  return [];
97
100
  }
@@ -2,8 +2,8 @@
2
2
  * Codex Adapter
3
3
  *
4
4
  * Detects running Codex agents by:
5
- * 1. Finding running codex processes via shared listAgentProcesses()
6
- * 2. Enriching with CWD and start times via shared enrichProcesses()
5
+ * 1. Filtering Codex processes from a shared asynchronous process snapshot
6
+ * 2. Using snapshot CWD and start-time enrichment
7
7
  * 3. Matching exact PID-to-session metadata from ~/.codex/ai-devkit/sessions.json
8
8
  * 4. Discovering session files from ~/.codex/sessions/YYYY/MM/DD/ via shared batchGetSessionFileBirthtimes()
9
9
  * 5. Setting resolvedCwd from session_meta first line
@@ -20,9 +20,10 @@ import type {
20
20
  ConversationMessage,
21
21
  SessionSummary,
22
22
  ListSessionsOptions,
23
+ AgentDetectionContext,
23
24
  } from './AgentAdapter.js';
24
25
  import { AgentStatus } from './AgentAdapter.js';
25
- import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
26
+ import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../utils/process.js';
26
27
  import { batchGetSessionFileBirthtimes, isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js';
27
28
  import type { SessionFile } from '../utils/session.js';
28
29
  import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js';
@@ -88,6 +89,7 @@ interface MappingMatchResult {
88
89
 
89
90
  export class CodexAdapter implements AgentAdapter {
90
91
  readonly type = 'codex' as const;
92
+ readonly processNames = ['codex'] as const;
91
93
 
92
94
  private static readonly IDLE_THRESHOLD_MINUTES = 5;
93
95
  /** Include session files around process start day to recover long-lived processes. */
@@ -111,8 +113,10 @@ export class CodexAdapter implements AgentAdapter {
111
113
  /**
112
114
  * Detect running Codex agents
113
115
  */
114
- async detectAgents(): Promise<AgentInfo[]> {
115
- const processes = enrichProcesses(listAgentProcesses('codex'));
116
+ async detectAgents(context?: AgentDetectionContext): Promise<AgentInfo[]> {
117
+ const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames);
118
+ const relevant = filterByProcessNames(snapshot, this.processNames);
119
+ const processes = relevant.filter((process) => this.canHandle(process));
116
120
  if (processes.length === 0) return [];
117
121
 
118
122
  const { cachedAgents, remaining } = this.tryRegistryCache(processes);
@@ -657,8 +661,7 @@ export class CodexAdapter implements AgentAdapter {
657
661
  }
658
662
 
659
663
  private isCodexExecutable(command: string): boolean {
660
- const executable = command.trim().split(/\s+/)[0] || '';
661
- const base = path.basename(executable).toLowerCase();
664
+ const base = executableBasename(command);
662
665
  return base === 'codex' || base === 'codex.exe';
663
666
  }
664
667
 
@@ -2,8 +2,8 @@
2
2
  * Copilot Adapter
3
3
  *
4
4
  * Detects running GitHub Copilot CLI agents by:
5
- * 1. Finding running copilot processes via shared listAgentProcesses()
6
- * 2. Enriching with CWD and start times via shared enrichProcesses()
5
+ * 1. Filtering Copilot processes from a shared asynchronous process snapshot
6
+ * 2. Using snapshot CWD and start-time enrichment
7
7
  * 3. Mapping active ~/.copilot/session-state/{sessionId}/inuse.{pid}.lock files to processes
8
8
  * 4. Reading events.jsonl as the primary session/conversation source
9
9
  * 5. Reading workspace.yaml as a flat fallback metadata source
@@ -17,9 +17,16 @@ import type {
17
17
  ListSessionsOptions,
18
18
  ProcessInfo,
19
19
  SessionSummary,
20
+ AgentDetectionContext,
20
21
  } from './AgentAdapter.js';
21
22
  import { AgentStatus } from './AgentAdapter.js';
22
- import { enrichProcesses, findWrapperProcess, findWrapperProcessPids, listAgentProcesses } from '../utils/process.js';
23
+ import {
24
+ captureProcessSnapshot,
25
+ executableBasename,
26
+ filterByProcessNames,
27
+ findWrapperProcess,
28
+ findWrapperProcessPids,
29
+ } from '../utils/process.js';
23
30
  import { generateAgentName } from '../utils/matching.js';
24
31
  import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js';
25
32
  import { AgentRegistry, type RegistryEntry } from '../utils/AgentRegistry.js';
@@ -89,6 +96,7 @@ interface CopilotEventSummary {
89
96
 
90
97
  export class CopilotAdapter implements AgentAdapter {
91
98
  readonly type = 'copilot' as const;
99
+ readonly processNames = ['copilot'] as const;
92
100
 
93
101
  private static readonly IDLE_THRESHOLD_MINUTES = 5;
94
102
  private static readonly VERBOSE_SYSTEM_EVENTS = new Set([
@@ -120,8 +128,10 @@ export class CopilotAdapter implements AgentAdapter {
120
128
  return this.isCopilotExecutable(processInfo.command);
121
129
  }
122
130
 
123
- async detectAgents(): Promise<AgentInfo[]> {
124
- const processes = enrichProcesses(listAgentProcesses('copilot'));
131
+ async detectAgents(context?: AgentDetectionContext): Promise<AgentInfo[]> {
132
+ const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames);
133
+ const relevant = filterByProcessNames(snapshot, this.processNames);
134
+ const processes = relevant.filter((process) => this.canHandle(process));
125
135
  if (processes.length === 0) return [];
126
136
 
127
137
  const processByPid = new Map(processes.map((proc) => [proc.pid, proc]));
@@ -457,8 +467,7 @@ export class CopilotAdapter implements AgentAdapter {
457
467
  }
458
468
 
459
469
  private isCopilotExecutable(command: string): boolean {
460
- const executable = command.trim().split(/\s+/)[0] || '';
461
- const base = path.basename(executable).toLowerCase();
470
+ const base = executableBasename(command);
462
471
  return base === 'copilot' || base === 'copilot.exe';
463
472
  }
464
473
  }
@@ -2,8 +2,8 @@
2
2
  * Gemini CLI Adapter
3
3
  *
4
4
  * Detects running Gemini CLI agents by:
5
- * 1. Finding running gemini processes via shared listAgentProcesses()
6
- * 2. Enriching with CWD and start times via shared enrichProcesses()
5
+ * 1. Filtering Gemini Node processes from a shared asynchronous process snapshot
6
+ * 2. Using snapshot CWD and start-time enrichment
7
7
  * 3. Discovering session files from ~/.gemini/tmp/<shortId>/chats/session-*.json
8
8
  * 4. Matching sessions to processes via shared matchProcessesToSessions()
9
9
  * using sha256(cwd) === session.projectHash as the resolvedCwd source
@@ -20,9 +20,16 @@ import type {
20
20
  ConversationMessage,
21
21
  SessionSummary,
22
22
  ListSessionsOptions,
23
+ AgentDetectionContext,
23
24
  } from './AgentAdapter.js';
24
25
  import { AgentStatus } from './AgentAdapter.js';
25
- import { listAgentProcesses, enrichProcesses, findWrapperProcess, findWrapperProcessPids } from '../utils/process.js';
26
+ import {
27
+ captureProcessSnapshot,
28
+ executableBasename,
29
+ filterByProcessNames,
30
+ findWrapperProcess,
31
+ findWrapperProcessPids,
32
+ } from '../utils/process.js';
26
33
  import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js';
27
34
  import type { SessionFile } from '../utils/session.js';
28
35
  import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js';
@@ -81,6 +88,7 @@ interface GeminiSession {
81
88
 
82
89
  export class GeminiCliAdapter implements AgentAdapter {
83
90
  readonly type = 'gemini_cli' as const;
91
+ readonly processNames = ['node'] as const;
84
92
 
85
93
  private static readonly IDLE_THRESHOLD_MINUTES = 5;
86
94
  private static readonly SESSION_FILE_PREFIX = 'session-';
@@ -109,12 +117,13 @@ export class GeminiCliAdapter implements AgentAdapter {
109
117
  * binary). The primary running process is therefore the Node runtime
110
118
  * itself, and `ps aux` lists it as `node /path/to/gemini ...` with
111
119
  * argv[0] = `node`. We scan the Node process pool via the shared
112
- * helper and keep only those whose command line references the gemini
120
+ * snapshot and keep only those whose command line references the gemini
113
121
  * executable or script via isGeminiExecutable().
114
122
  */
115
- async detectAgents(): Promise<AgentInfo[]> {
116
- const nodeProcesses = enrichProcesses(listAgentProcesses('node'));
117
- const processes = nodeProcesses.filter((proc) => this.isGeminiExecutable(proc.command));
123
+ async detectAgents(context?: AgentDetectionContext): Promise<AgentInfo[]> {
124
+ const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames);
125
+ const relevant = filterByProcessNames(snapshot, this.processNames);
126
+ const processes = relevant.filter((process) => this.canHandle(process));
118
127
  if (processes.length === 0) return [];
119
128
 
120
129
  const wrapperPids = findWrapperProcessPids(processes);
@@ -541,7 +550,7 @@ export class GeminiCliAdapter implements AgentAdapter {
541
550
  // other adapters' argv[0]-only check because the Node-script
542
551
  // distribution puts the real gemini path in argv[1..], not argv[0].
543
552
  for (const token of command.trim().split(/\s+/)) {
544
- const base = path.basename(token).toLowerCase();
553
+ const base = executableBasename(token);
545
554
  if (base === 'gemini' || base === 'gemini.exe' || base === 'gemini.js') {
546
555
  return true;
547
556
  }
@@ -6,9 +6,10 @@ import type {
6
6
  ConversationMessage,
7
7
  SessionSummary,
8
8
  ListSessionsOptions,
9
+ AgentDetectionContext,
9
10
  } from './AgentAdapter.js';
10
11
  import { AgentStatus } from './AgentAdapter.js';
11
- import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
12
+ import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../utils/process.js';
12
13
  import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js';
13
14
  import { generateAgentName } from '../utils/matching.js';
14
15
 
@@ -16,7 +17,7 @@ import { generateAgentName } from '../utils/matching.js';
16
17
  * Grok Build CLI Adapter
17
18
  *
18
19
  * Detects running Grok Build CLI agents by:
19
- * 1. Finding running `grok` processes via shared listAgentProcesses() — Grok is
20
+ * 1. Filtering `grok` processes from a shared asynchronous snapshot — Grok is
20
21
  * a native binary at ~/.grok/bin/grok, so argv[0] basename is `grok`.
21
22
  * 2. Resolving each live process to its working directory via
22
23
  * ~/.grok/active_sessions.json, which Grok maintains as a list of
@@ -69,6 +70,7 @@ interface GrokSession {
69
70
 
70
71
  export class GrokCliAdapter implements AgentAdapter {
71
72
  readonly type = 'grok_cli' as const;
73
+ readonly processNames = ['grok'] as const;
72
74
 
73
75
  private base: string;
74
76
  private sessionsDir: string;
@@ -87,13 +89,14 @@ export class GrokCliAdapter implements AgentAdapter {
87
89
  }
88
90
 
89
91
  private isGrokExecutable(command: string): boolean {
90
- const executable = command.trim().split(/\s+/)[0] || '';
91
- const base = path.basename(executable).toLowerCase();
92
+ const base = executableBasename(command);
92
93
  return base === 'grok' || base === 'grok.exe';
93
94
  }
94
95
 
95
- async detectAgents(): Promise<AgentInfo[]> {
96
- const processes = enrichProcesses(listAgentProcesses('grok'));
96
+ async detectAgents(context?: AgentDetectionContext): Promise<AgentInfo[]> {
97
+ const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames);
98
+ const relevant = filterByProcessNames(snapshot, this.processNames);
99
+ const processes = relevant.filter((process) => this.canHandle(process));
97
100
  if (processes.length === 0) {
98
101
  return [];
99
102
  }
@@ -2,8 +2,8 @@
2
2
  * OpenCode Adapter
3
3
  *
4
4
  * Detects running OpenCode agents by:
5
- * 1. Finding running opencode processes via shared listAgentProcesses()
6
- * 2. Enriching with CWD and start times via shared enrichProcesses()
5
+ * 1. Filtering OpenCode processes from a shared asynchronous process snapshot
6
+ * 2. Using snapshot CWD and start-time enrichment
7
7
  * 3. Querying OpenCode's SQLite DB (~/.local/share/opencode/opencode.db) to
8
8
  * find the session matching each process's CWD and read status from message.time.completed
9
9
  *
@@ -21,9 +21,10 @@ import type {
21
21
  ConversationMessage,
22
22
  SessionSummary,
23
23
  ListSessionsOptions,
24
+ AgentDetectionContext,
24
25
  } from './AgentAdapter.js';
25
26
  import { AgentStatus } from './AgentAdapter.js';
26
- import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
27
+ import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../utils/process.js';
27
28
  import { generateAgentName } from '../utils/matching.js';
28
29
 
29
30
  const SESSION_REF_SEP = '::';
@@ -55,6 +56,7 @@ interface OpenCodeSessionStats {
55
56
 
56
57
  export class OpenCodeAdapter implements AgentAdapter {
57
58
  readonly type = 'opencode' as const;
59
+ readonly processNames = ['opencode'] as const;
58
60
 
59
61
  private static readonly IDLE_THRESHOLD_MINUTES = 5;
60
62
 
@@ -84,13 +86,14 @@ export class OpenCodeAdapter implements AgentAdapter {
84
86
  }
85
87
 
86
88
  canHandle(processInfo: ProcessInfo): boolean {
87
- const exe = (processInfo.command.trim().split(/\s+/)[0] || '').toLowerCase();
88
- const base = path.basename(exe);
89
+ const base = executableBasename(processInfo.command);
89
90
  return base === 'opencode' || base === 'opencode.exe';
90
91
  }
91
92
 
92
- async detectAgents(): Promise<AgentInfo[]> {
93
- const processes = enrichProcesses(listAgentProcesses('opencode'));
93
+ async detectAgents(context?: AgentDetectionContext): Promise<AgentInfo[]> {
94
+ const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames);
95
+ const relevant = filterByProcessNames(snapshot, this.processNames);
96
+ const processes = relevant.filter((process) => this.canHandle(process));
94
97
  if (processes.length === 0) return [];
95
98
 
96
99
  const db = this.openDb();
@@ -17,9 +17,10 @@ import type {
17
17
  ConversationMessage,
18
18
  SessionSummary,
19
19
  ListSessionsOptions,
20
+ AgentDetectionContext,
20
21
  } from './AgentAdapter.js';
21
22
  import { AgentStatus } from './AgentAdapter.js';
22
- import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
23
+ import { captureProcessSnapshot, executableBasename, filterByProcessNames } from '../utils/process.js';
23
24
  import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js';
24
25
  import type { SessionFile } from '../utils/session.js';
25
26
  import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js';
@@ -66,6 +67,7 @@ interface TrackerAgentResult {
66
67
 
67
68
  export class PiAdapter implements AgentAdapter {
68
69
  readonly type = 'pi' as const;
70
+ readonly processNames = ['pi', 'node'] as const;
69
71
 
70
72
  private static readonly IDLE_THRESHOLD_MINUTES = 5;
71
73
 
@@ -86,8 +88,10 @@ export class PiAdapter implements AgentAdapter {
86
88
  return this.isPiExecutable(processInfo.command);
87
89
  }
88
90
 
89
- async detectAgents(): Promise<AgentInfo[]> {
90
- const processes = enrichProcesses(this.listPiProcesses());
91
+ async detectAgents(context?: AgentDetectionContext): Promise<AgentInfo[]> {
92
+ const snapshot = context?.processes ?? await captureProcessSnapshot(this.processNames);
93
+ const relevant = filterByProcessNames(snapshot, this.processNames);
94
+ const processes = this.listPiProcesses(relevant);
91
95
  if (processes.length === 0) return [];
92
96
 
93
97
  const { cachedAgents, remaining } = this.tryRegistryCache(processes);
@@ -149,12 +153,9 @@ export class PiAdapter implements AgentAdapter {
149
153
  return agents;
150
154
  }
151
155
 
152
- private listPiProcesses(): ProcessInfo[] {
156
+ private listPiProcesses(snapshot: readonly ProcessInfo[]): ProcessInfo[] {
153
157
  const byPid = new Map<number, ProcessInfo>();
154
- for (const proc of listAgentProcesses('pi')) {
155
- if (this.canHandle(proc)) byPid.set(proc.pid, proc);
156
- }
157
- for (const proc of listAgentProcesses('node')) {
158
+ for (const proc of snapshot) {
158
159
  if (this.canHandle(proc)) byPid.set(proc.pid, proc);
159
160
  }
160
161
  return Array.from(byPid.values());
@@ -547,7 +548,7 @@ export class PiAdapter implements AgentAdapter {
547
548
 
548
549
  private isPiExecutable(command: string): boolean {
549
550
  for (const token of command.trim().split(/\s+/)) {
550
- const base = path.basename(token).toLowerCase();
551
+ const base = executableBasename(token);
551
552
  if (base === 'pi' || base === 'pi.exe' || base === 'pi.js') return true;
552
553
  }
553
554
  return false;
@@ -6,4 +6,4 @@ export { GrokCliAdapter } from './GrokCliAdapter.js';
6
6
  export { OpenCodeAdapter } from './OpenCodeAdapter.js';
7
7
  export { PiAdapter } from './PiAdapter.js';
8
8
  export { AgentStatus } from './AgentAdapter.js';
9
- export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './AgentAdapter.js';
9
+ export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo, AgentDetectionContext } from './AgentAdapter.js';
@@ -8,7 +8,7 @@ export const DEFAULT_AGENT_REGISTRY_DB_PATH = join(homedir(), '.ai-devkit', 'age
8
8
 
9
9
  export interface DatabaseOptions {
10
10
  dbPath?: string;
11
- verbose?: boolean;
11
+ verbose?: boolean | ((message: string) => void);
12
12
  readonly?: boolean;
13
13
  }
14
14
 
@@ -27,7 +27,9 @@ export class DatabaseConnection {
27
27
 
28
28
  this.db = new Database(this.dbPath, {
29
29
  readonly: options.readonly ?? false,
30
- verbose: options.verbose ? console.log : undefined,
30
+ verbose: typeof options.verbose === 'function'
31
+ ? options.verbose
32
+ : options.verbose ? console.log : undefined,
31
33
  });
32
34
 
33
35
  this.configure();
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export type {
16
16
  ConversationMessage,
17
17
  SessionSummary,
18
18
  ListSessionsOptions,
19
+ AgentDetectionContext,
19
20
  } from './adapters/AgentAdapter.js';
20
21
 
21
22
  export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager.js';
@@ -23,11 +24,12 @@ export type { TerminalLocation } from './terminal/TerminalFocusManager.js';
23
24
  export { TtyWriter } from './terminal/TtyWriter.js';
24
25
 
25
26
  export { getProcessTty } from './utils/process.js';
27
+ export { captureProcessSnapshot, executableBasename, filterByProcessNames } from './utils/process.js';
26
28
  export type { AgentSortKey } from './utils/sortAgents.js';
27
29
  export type { ListAgentsOptions } from './AgentManager.js';
28
30
 
29
31
  export { AgentRegistry, RenameNotFoundError, RenameConflictError } from './utils/AgentRegistry.js';
30
- export type { RegistryEntry } from './utils/AgentRegistry.js';
32
+ export type { AgentRegistryOptions, RegistryEntry } from './utils/AgentRegistry.js';
31
33
  export { TmuxManager } from './terminal/TmuxManager.js';
32
34
  export { AGENTS } from './utils/agents.js';
33
35
  export type { AgentConfig, StartableAgentType } from './utils/agents.js';
@@ -44,14 +44,29 @@ interface RegistryRow {
44
44
  }
45
45
 
46
46
  const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json');
47
+ const DEFAULT_PRUNE_INTERVAL_MS = 30_000;
47
48
 
48
49
  let defaultInstance: AgentRegistry | null = null;
49
50
 
51
+ export interface AgentRegistryOptions {
52
+ now?: () => Date;
53
+ pruneIntervalMs?: number;
54
+ onDatabaseOperation?: (sql: string) => void;
55
+ }
56
+
50
57
  export class AgentRegistry {
51
58
  private db: DatabaseConnection;
59
+ private readonly now: () => Date;
60
+ private readonly pruneIntervalMs: number;
61
+ private lastPrunedAt: number | undefined;
52
62
 
53
- constructor(filePath: string = DEFAULT_REGISTRY_PATH) {
54
- this.db = new DatabaseConnection({ dbPath: resolveAgentRegistryDbPath(filePath) });
63
+ constructor(filePath: string = DEFAULT_REGISTRY_PATH, options: AgentRegistryOptions = {}) {
64
+ this.now = options.now ?? (() => new Date());
65
+ this.pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS;
66
+ this.db = new DatabaseConnection({
67
+ dbPath: resolveAgentRegistryDbPath(filePath),
68
+ verbose: options.onDatabaseOperation,
69
+ });
55
70
  }
56
71
 
57
72
  static default(): AgentRegistry {
@@ -101,6 +116,24 @@ export class AgentRegistry {
101
116
  return row ? this.rowToEntry(row) : undefined;
102
117
  }
103
118
 
119
+ private findPidConflicts(type: AgentType, pid: number): RegistryEntry[] {
120
+ return this.db.query<RegistryRow>(
121
+ 'SELECT * FROM agents WHERE pid = ? AND type <> ?',
122
+ [pid, type],
123
+ ).map((row) => this.rowToEntry(row));
124
+ }
125
+
126
+ private entriesEqual(left: RegistryEntry, right: RegistryEntry): boolean {
127
+ return left.name === right.name
128
+ && left.type === right.type
129
+ && left.pid === right.pid
130
+ && left.tmuxSession === right.tmuxSession
131
+ && left.cwd === right.cwd
132
+ && left.startedAt === right.startedAt
133
+ && left.sessionId === right.sessionId
134
+ && left.sessionFilePath === right.sessionFilePath;
135
+ }
136
+
104
137
  private deleteNameConflict(name: string, type: AgentType, pid: number): void {
105
138
  const conflict = this.findByName(name);
106
139
  if (!conflict) return;
@@ -126,31 +159,66 @@ export class AgentRegistry {
126
159
  session_id = excluded.session_id,
127
160
  session_file_path = excluded.session_file_path,
128
161
  updated_at = excluded.updated_at
129
- `).run({ ...entry, updatedAt: new Date().toISOString() });
162
+ `).run({ ...entry, updatedAt: this.now().toISOString() });
130
163
  }
131
164
 
132
- private save(entry: RegistryEntry): void {
133
- this.deleteNameConflict(entry.name, entry.type, entry.pid);
134
- this.insertOrUpdate(entry);
165
+ private needsWrite(incoming: RegistryEntry): boolean {
166
+ const existing = this.findByIdentity(incoming.type, incoming.pid);
167
+ const merged = this.mergeEntry(incoming, existing);
168
+ return !existing
169
+ || !this.entriesEqual(merged, existing)
170
+ || this.findPidConflicts(incoming.type, incoming.pid).length > 0;
171
+ }
172
+
173
+ private save(incoming: RegistryEntry): void {
174
+ const existing = this.findByIdentity(incoming.type, incoming.pid);
175
+ const merged = this.mergeEntry(incoming, existing);
176
+ const pidConflicts = this.findPidConflicts(incoming.type, incoming.pid);
177
+ if (existing && this.entriesEqual(merged, existing) && pidConflicts.length === 0) return;
178
+
179
+ for (const conflict of pidConflicts) {
180
+ this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);
181
+ }
182
+ if (existing && this.entriesEqual(merged, existing)) return;
183
+
184
+ this.deleteNameConflict(merged.name, merged.type, merged.pid);
185
+ this.insertOrUpdate(merged);
135
186
  }
136
187
 
137
188
  isAlive(entry: RegistryEntry): boolean {
138
189
  try {
139
190
  process.kill(entry.pid, 0);
140
191
  return true;
141
- } catch {
142
- return false;
192
+ } catch (error) {
193
+ const code = error && typeof error === 'object' && 'code' in error
194
+ ? error.code
195
+ : undefined;
196
+ return code !== 'ESRCH';
143
197
  }
144
198
  }
145
199
 
146
- prune(): void {
200
+ private pruneAt(nowMs: number): void {
147
201
  const entries = this.list();
148
202
  const stale = entries.filter((e) => !this.isAlive(e));
149
- this.db.transaction(() => {
150
- for (const entry of stale) {
151
- this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]);
152
- }
153
- });
203
+ if (stale.length > 0) {
204
+ this.db.transaction(() => {
205
+ for (const entry of stale) {
206
+ this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]);
207
+ }
208
+ });
209
+ }
210
+ this.lastPrunedAt = nowMs;
211
+ }
212
+
213
+ prune(): void {
214
+ this.pruneAt(this.now().getTime());
215
+ }
216
+
217
+ pruneIfDue(): void {
218
+ const nowMs = this.now().getTime();
219
+ const elapsed = this.lastPrunedAt === undefined ? undefined : nowMs - this.lastPrunedAt;
220
+ if (elapsed !== undefined && elapsed >= 0 && elapsed < this.pruneIntervalMs) return;
221
+ this.pruneAt(nowMs);
154
222
  }
155
223
 
156
224
  register(entry: RegistryEntry): void {
@@ -159,10 +227,10 @@ export class AgentRegistry {
159
227
 
160
228
  registerBatch(entries: RegistryEntry[]): void {
161
229
  if (entries.length === 0) return;
230
+ if (!entries.some((entry) => this.needsWrite(entry))) return;
162
231
  this.db.transaction(() => {
163
232
  for (const incoming of entries) {
164
- const existing = this.findByIdentity(incoming.type, incoming.pid);
165
- this.save(this.mergeEntry(incoming, existing));
233
+ this.save(incoming);
166
234
  }
167
235
  });
168
236
  }
@@ -183,7 +251,7 @@ export class AgentRegistry {
183
251
  }
184
252
  this.db.execute(
185
253
  'UPDATE agents SET name = ?, updated_at = ? WHERE type = ? AND pid = ?',
186
- [newName, new Date().toISOString(), existing.type, existing.pid],
254
+ [newName, this.now().toISOString(), existing.type, existing.pid],
187
255
  );
188
256
  });
189
257
  }
@@ -1,4 +1,4 @@
1
- export { listAgentProcesses, batchGetProcessCwds, batchGetProcessStartTimes, enrichProcesses } from './process.js';
1
+ export { listAgentProcesses, batchGetProcessCwds, batchGetProcessStartTimes, enrichProcesses, captureProcessSnapshot } from './process.js';
2
2
  export { getProcessTty } from './process.js';
3
3
  export { batchGetSessionFileBirthtimes } from './session.js';
4
4
  export type { SessionFile } from './session.js';