@ai-devkit/agent-manager 0.15.0 → 0.17.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 (112) hide show
  1. package/.swcrc +27 -0
  2. package/dist/AgentManager.d.ts +4 -0
  3. package/dist/AgentManager.d.ts.map +1 -1
  4. package/dist/AgentManager.js +97 -64
  5. package/dist/AgentManager.js.map +1 -1
  6. package/dist/__tests__/AgentManager.test.js +564 -0
  7. package/dist/__tests__/AgentManager.test.js.map +1 -0
  8. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js +2157 -0
  9. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js.map +1 -0
  10. package/dist/__tests__/adapters/CodexAdapter.test.js +1354 -0
  11. package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -0
  12. package/dist/__tests__/adapters/CopilotAdapter.test.js +500 -0
  13. package/dist/__tests__/adapters/CopilotAdapter.test.js.map +1 -0
  14. package/dist/__tests__/adapters/GeminiCliAdapter.test.js +1229 -0
  15. package/dist/__tests__/adapters/GeminiCliAdapter.test.js.map +1 -0
  16. package/dist/__tests__/adapters/OpenCodeAdapter.test.js +626 -0
  17. package/dist/__tests__/adapters/OpenCodeAdapter.test.js.map +1 -0
  18. package/dist/__tests__/terminal/TmuxManager.test.js +159 -0
  19. package/dist/__tests__/terminal/TmuxManager.test.js.map +1 -0
  20. package/dist/__tests__/terminal/TtyWriter.test.js +162 -0
  21. package/dist/__tests__/terminal/TtyWriter.test.js.map +1 -0
  22. package/dist/__tests__/utils/AgentRegistry.test.js +282 -0
  23. package/dist/__tests__/utils/AgentRegistry.test.js.map +1 -0
  24. package/dist/__tests__/utils/ClaudeSessionParser.test.js +312 -0
  25. package/dist/__tests__/utils/ClaudeSessionParser.test.js.map +1 -0
  26. package/dist/__tests__/utils/matching.test.js +324 -0
  27. package/dist/__tests__/utils/matching.test.js.map +1 -0
  28. package/dist/__tests__/utils/process.test.js +177 -0
  29. package/dist/__tests__/utils/process.test.js.map +1 -0
  30. package/dist/__tests__/utils/session.test.js +158 -0
  31. package/dist/__tests__/utils/session.test.js.map +1 -0
  32. package/dist/adapters/AgentAdapter.d.ts +1 -1
  33. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  34. package/dist/adapters/AgentAdapter.js +8 -7
  35. package/dist/adapters/AgentAdapter.js.map +1 -1
  36. package/dist/adapters/ClaudeCodeAdapter.js +66 -77
  37. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  38. package/dist/adapters/CodexAdapter.d.ts +13 -1
  39. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  40. package/dist/adapters/CodexAdapter.js +208 -132
  41. package/dist/adapters/CodexAdapter.js.map +1 -1
  42. package/dist/adapters/CopilotAdapter.d.ts +44 -0
  43. package/dist/adapters/CopilotAdapter.d.ts.map +1 -0
  44. package/dist/adapters/CopilotAdapter.js +314 -0
  45. package/dist/adapters/CopilotAdapter.js.map +1 -0
  46. package/dist/adapters/GeminiCliAdapter.d.ts +6 -1
  47. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  48. package/dist/adapters/GeminiCliAdapter.js +160 -149
  49. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  50. package/dist/adapters/OpenCodeAdapter.js +60 -69
  51. package/dist/adapters/OpenCodeAdapter.js.map +1 -1
  52. package/dist/adapters/index.d.ts +1 -0
  53. package/dist/adapters/index.d.ts.map +1 -1
  54. package/dist/adapters/index.js +2 -0
  55. package/dist/adapters/index.js.map +1 -1
  56. package/dist/index.d.ts +6 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +5 -0
  59. package/dist/index.js.map +1 -1
  60. package/dist/terminal/TerminalFocusManager.js +62 -49
  61. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  62. package/dist/terminal/TmuxManager.d.ts +28 -0
  63. package/dist/terminal/TmuxManager.d.ts.map +1 -0
  64. package/dist/terminal/TmuxManager.js +137 -0
  65. package/dist/terminal/TmuxManager.js.map +1 -0
  66. package/dist/terminal/TtyWriter.js +37 -16
  67. package/dist/terminal/TtyWriter.js.map +1 -1
  68. package/dist/terminal/index.js +1 -0
  69. package/dist/terminal/index.js.map +1 -1
  70. package/dist/utils/AgentRegistry.d.ts +35 -0
  71. package/dist/utils/AgentRegistry.d.ts.map +1 -0
  72. package/dist/utils/AgentRegistry.js +123 -0
  73. package/dist/utils/AgentRegistry.js.map +1 -0
  74. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
  75. package/dist/utils/ClaudeSessionParser.js +62 -93
  76. package/dist/utils/ClaudeSessionParser.js.map +1 -1
  77. package/dist/utils/agents.d.ts +15 -0
  78. package/dist/utils/agents.d.ts.map +1 -0
  79. package/dist/utils/agents.js +41 -0
  80. package/dist/utils/agents.js.map +1 -0
  81. package/dist/utils/applescript.js +3 -6
  82. package/dist/utils/applescript.js.map +1 -1
  83. package/dist/utils/index.js +1 -0
  84. package/dist/utils/index.js.map +1 -1
  85. package/dist/utils/matching.js +21 -29
  86. package/dist/utils/matching.js.map +1 -1
  87. package/dist/utils/process.js +77 -56
  88. package/dist/utils/process.js.map +1 -1
  89. package/dist/utils/session.js +20 -34
  90. package/dist/utils/session.js.map +1 -1
  91. package/dist/utils/sortAgents.js +6 -8
  92. package/dist/utils/sortAgents.js.map +1 -1
  93. package/package.json +11 -7
  94. package/src/AgentManager.ts +40 -0
  95. package/src/__tests__/AgentManager.test.ts +135 -0
  96. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +3 -2
  97. package/src/__tests__/adapters/CodexAdapter.test.ts +199 -0
  98. package/src/__tests__/adapters/CopilotAdapter.test.ts +411 -0
  99. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +185 -1
  100. package/src/__tests__/terminal/TmuxManager.test.ts +175 -0
  101. package/src/__tests__/utils/AgentRegistry.test.ts +223 -0
  102. package/src/__tests__/utils/ClaudeSessionParser.test.ts +71 -0
  103. package/src/adapters/AgentAdapter.ts +1 -1
  104. package/src/adapters/CodexAdapter.ts +155 -8
  105. package/src/adapters/CopilotAdapter.ts +453 -0
  106. package/src/adapters/GeminiCliAdapter.ts +84 -6
  107. package/src/adapters/index.ts +1 -0
  108. package/src/index.ts +7 -0
  109. package/src/terminal/TmuxManager.ts +118 -0
  110. package/src/utils/AgentRegistry.ts +139 -0
  111. package/src/utils/ClaudeSessionParser.ts +21 -10
  112. package/src/utils/agents.ts +41 -0
@@ -26,6 +26,7 @@ import { listAgentProcesses, enrichProcesses } 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
30
 
30
31
  /**
31
32
  * A single Gemini CLI message content part. Mirrors the `{text?: string}`
@@ -87,10 +88,12 @@ export class GeminiCliAdapter implements AgentAdapter {
87
88
  private static readonly TMP_DIR_NAME = 'tmp';
88
89
 
89
90
  private geminiTmpDir: string;
91
+ private registry: AgentRegistry;
90
92
 
91
- constructor() {
93
+ constructor(registry: AgentRegistry = AgentRegistry.default()) {
92
94
  const homeDir = process.env.HOME || process.env.USERPROFILE || '';
93
95
  this.geminiTmpDir = path.join(homeDir, '.gemini', GeminiCliAdapter.TMP_DIR_NAME);
96
+ this.registry = registry;
94
97
  }
95
98
 
96
99
  canHandle(processInfo: ProcessInfo): boolean {
@@ -114,12 +117,18 @@ export class GeminiCliAdapter implements AgentAdapter {
114
117
  const processes = nodeProcesses.filter((proc) => this.isGeminiExecutable(proc.command));
115
118
  if (processes.length === 0) return [];
116
119
 
117
- const { sessions, contentCache } = this.discoverSessions(processes);
120
+ const { cachedAgents, remaining } = this.tryRegistryCache(processes);
121
+ if (remaining.length === 0) return this.deduplicateSessionAgents(cachedAgents);
122
+
123
+ const { sessions, contentCache } = this.discoverSessions(remaining);
118
124
  if (sessions.length === 0) {
119
- return processes.map((p) => this.mapProcessOnlyAgent(p));
125
+ return this.deduplicateSessionAgents([
126
+ ...cachedAgents,
127
+ ...remaining.map((p) => this.mapProcessOnlyAgent(p)),
128
+ ]);
120
129
  }
121
130
 
122
- const matches = matchProcessesToSessions(processes, sessions);
131
+ const matches = matchProcessesToSessions(remaining, sessions);
123
132
  const matchedPids = new Set(matches.map((m) => m.process.pid));
124
133
  const agents: AgentInfo[] = [];
125
134
 
@@ -133,13 +142,82 @@ export class GeminiCliAdapter implements AgentAdapter {
133
142
  }
134
143
  }
135
144
 
136
- for (const proc of processes) {
145
+ for (const proc of remaining) {
137
146
  if (!matchedPids.has(proc.pid)) {
138
147
  agents.push(this.mapProcessOnlyAgent(proc));
139
148
  }
140
149
  }
141
150
 
142
- return agents;
151
+ return this.deduplicateSessionAgents([...cachedAgents, ...agents]);
152
+ }
153
+
154
+ private deduplicateSessionAgents(agents: AgentInfo[]): AgentInfo[] {
155
+ const bySession = new Map<string, AgentInfo>();
156
+ const result: AgentInfo[] = [];
157
+
158
+ for (const agent of agents) {
159
+ const sessionKey = this.sessionDeduplicationKey(agent);
160
+ if (!sessionKey) {
161
+ result.push(agent);
162
+ continue;
163
+ }
164
+
165
+ const existing = bySession.get(sessionKey);
166
+ if (!existing) {
167
+ bySession.set(sessionKey, agent);
168
+ result.push(agent);
169
+ continue;
170
+ }
171
+
172
+ if (agent.pid > existing.pid) {
173
+ bySession.set(sessionKey, agent);
174
+ const index = result.indexOf(existing);
175
+ if (index >= 0) result[index] = agent;
176
+ }
177
+ }
178
+
179
+ return result;
180
+ }
181
+
182
+ private sessionDeduplicationKey(agent: AgentInfo): string | null {
183
+ if (agent.sessionFilePath) return `file:${agent.sessionFilePath}`;
184
+ if (agent.sessionId && !agent.sessionId.startsWith('pid-')) {
185
+ return `session:${agent.sessionId}`;
186
+ }
187
+ return null;
188
+ }
189
+
190
+ private tryRegistryCache(processes: ProcessInfo[]): {
191
+ cachedAgents: AgentInfo[];
192
+ remaining: ProcessInfo[];
193
+ } {
194
+ const cachedAgents: AgentInfo[] = [];
195
+ const remaining: ProcessInfo[] = [];
196
+ const byPid = new Map(this.registry.list().map((e) => [e.pid, e]));
197
+
198
+ for (const proc of processes) {
199
+ const entry = byPid.get(proc.pid);
200
+ if (
201
+ !entry ||
202
+ entry.type !== this.type ||
203
+ !entry.sessionFilePath ||
204
+ !fs.existsSync(entry.sessionFilePath)
205
+ ) {
206
+ remaining.push(proc);
207
+ continue;
208
+ }
209
+
210
+ const content = safeReadFile(entry.sessionFilePath);
211
+ const sessionData = this.parseSession(content, entry.sessionFilePath);
212
+ if (!sessionData) {
213
+ remaining.push(proc);
214
+ continue;
215
+ }
216
+
217
+ cachedAgents.push(this.mapSessionToAgent(sessionData, proc, entry.sessionFilePath));
218
+ }
219
+
220
+ return { cachedAgents, remaining };
143
221
  }
144
222
 
145
223
  /**
@@ -1,5 +1,6 @@
1
1
  export { ClaudeCodeAdapter } from './ClaudeCodeAdapter.js';
2
2
  export { CodexAdapter } from './CodexAdapter.js';
3
+ export { CopilotAdapter } from './CopilotAdapter.js';
3
4
  export { GeminiCliAdapter } from './GeminiCliAdapter.js';
4
5
  export { OpenCodeAdapter } from './OpenCodeAdapter.js';
5
6
  export { AgentStatus } from './AgentAdapter.js';
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export { AgentManager } from './AgentManager.js';
2
2
 
3
3
  export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter.js';
4
4
  export { CodexAdapter } from './adapters/CodexAdapter.js';
5
+ export { CopilotAdapter } from './adapters/CopilotAdapter.js';
5
6
  export { GeminiCliAdapter } from './adapters/GeminiCliAdapter.js';
6
7
  export { OpenCodeAdapter } from './adapters/OpenCodeAdapter.js';
7
8
  export { AgentStatus } from './adapters/AgentAdapter.js';
@@ -22,3 +23,9 @@ export { TtyWriter } from './terminal/TtyWriter.js';
22
23
  export { getProcessTty } from './utils/process.js';
23
24
  export type { AgentSortKey } from './utils/sortAgents.js';
24
25
  export type { ListAgentsOptions } from './AgentManager.js';
26
+
27
+ export { AgentRegistry, RenameNotFoundError, RenameConflictError } from './utils/AgentRegistry.js';
28
+ export type { RegistryEntry } from './utils/AgentRegistry.js';
29
+ export { TmuxManager } from './terminal/TmuxManager.js';
30
+ export { AGENTS } from './utils/agents.js';
31
+ export type { AgentConfig, StartableAgentType } from './utils/agents.js';
@@ -0,0 +1,118 @@
1
+ import { execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export class TmuxManager {
7
+ async isAvailable(): Promise<boolean> {
8
+ try {
9
+ await execFileAsync('tmux', ['-V']);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ async sessionExists(name: string): Promise<boolean> {
17
+ try {
18
+ await execFileAsync('tmux', ['has-session', '-t', name]);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ async createSession(name: string, cwd: string): Promise<void> {
26
+ await execFileAsync('tmux', ['new-session', '-d', '-s', name, '-c', cwd]);
27
+ }
28
+
29
+ async sendKeys(session: string, keys: string): Promise<void> {
30
+ await execFileAsync('tmux', ['send-keys', '-t', session, keys, 'Enter']);
31
+ }
32
+
33
+ async killSession(name: string): Promise<void> {
34
+ try {
35
+ await execFileAsync('tmux', ['kill-session', '-t', name]);
36
+ } catch {
37
+ // Already gone — ignore
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Find the actual agent process PID inside a tmux pane.
43
+ *
44
+ * Strategy: BFS the process tree, return the deepest descendant whose
45
+ * `ps` command line is accepted by `matches`. The caller supplies the
46
+ * matcher so this method has no agent-type knowledge.
47
+ *
48
+ * This handles two real-world process shapes:
49
+ * - Wrapper case: shell → claude-wrapper (matches) → claude (matches, deeper)
50
+ * → returns the deeper one
51
+ * - Subprocess case: shell → claude (matches) → MCP server child (doesn't match)
52
+ * → returns claude, not the subprocess
53
+ *
54
+ * Returns null when no descendant matches yet (agent still starting); the
55
+ * caller's poll loop retries.
56
+ */
57
+ async findAgentPid(session: string, matches: (psCommand: string) => boolean): Promise<number | null> {
58
+ const panePid = await this.getPanePid(session);
59
+ if (panePid === null) return null;
60
+
61
+ const visited = new Set<number>();
62
+ const queue: number[] = [panePid];
63
+ let deepestMatch: number | null = null;
64
+
65
+ while (queue.length > 0) {
66
+ const pid = queue.shift()!;
67
+ if (visited.has(pid)) continue;
68
+ visited.add(pid);
69
+
70
+ if (pid !== panePid) {
71
+ const command = await this.getProcessCommand(pid);
72
+ if (command && matches(command)) {
73
+ deepestMatch = pid;
74
+ }
75
+ }
76
+
77
+ const children = await this.pgrepChildren(pid);
78
+ queue.push(...children);
79
+ }
80
+
81
+ return deepestMatch;
82
+ }
83
+
84
+ private async getPanePid(session: string): Promise<number | null> {
85
+ try {
86
+ const { stdout } = await execFileAsync('tmux', [
87
+ 'list-panes', '-t', session, '-F', '#{pane_pid}',
88
+ ]);
89
+ const panePid = parseInt(stdout.trim().split('\n')[0], 10);
90
+ return isNaN(panePid) ? null : panePid;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ private async pgrepChildren(pid: number): Promise<number[]> {
97
+ try {
98
+ const { stdout } = await execFileAsync('pgrep', ['-P', String(pid)]);
99
+ return stdout
100
+ .trim()
101
+ .split('\n')
102
+ .map((s) => parseInt(s, 10))
103
+ .filter((n) => !isNaN(n));
104
+ } catch {
105
+ return [];
106
+ }
107
+ }
108
+
109
+ private async getProcessCommand(pid: number): Promise<string | null> {
110
+ try {
111
+ const { stdout } = await execFileAsync('ps', ['-p', String(pid), '-o', 'command=']);
112
+ const trimmed = stdout.trim();
113
+ return trimmed || null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,139 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import type { AgentType } from '../adapters/AgentAdapter.js';
5
+
6
+ export class RenameNotFoundError extends Error {
7
+ constructor(public agentName: string) {
8
+ super(`Agent "${agentName}" not found in registry.`);
9
+ this.name = 'RenameNotFoundError';
10
+ }
11
+ }
12
+
13
+ export class RenameConflictError extends Error {
14
+ constructor(public agentName: string) {
15
+ super(`Agent "${agentName}" is already in use.`);
16
+ this.name = 'RenameConflictError';
17
+ }
18
+ }
19
+
20
+ export interface RegistryEntry {
21
+ name: string;
22
+ type: AgentType;
23
+ pid: number;
24
+ tmuxSession: string;
25
+ cwd: string;
26
+ startedAt: string; // ISO 8601
27
+ sessionId: string;
28
+ sessionFilePath: string;
29
+ }
30
+
31
+ interface RegistryFile {
32
+ entries: RegistryEntry[];
33
+ }
34
+
35
+ const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json');
36
+
37
+ let defaultInstance: AgentRegistry | null = null;
38
+
39
+ export class AgentRegistry {
40
+ private filePath: string;
41
+
42
+ constructor(filePath: string = DEFAULT_REGISTRY_PATH) {
43
+ this.filePath = filePath;
44
+ }
45
+
46
+ static default(): AgentRegistry {
47
+ if (!defaultInstance) {
48
+ defaultInstance = new AgentRegistry();
49
+ }
50
+ return defaultInstance;
51
+ }
52
+
53
+ private readFile(): RegistryFile {
54
+ try {
55
+ const raw = fs.readFileSync(this.filePath, 'utf8');
56
+ const parsed = JSON.parse(raw) as RegistryFile;
57
+ return { entries: Array.isArray(parsed.entries) ? parsed.entries : [] };
58
+ } catch {
59
+ return { entries: [] };
60
+ }
61
+ }
62
+
63
+ private writeFile(data: RegistryFile): void {
64
+ const dir = path.dirname(this.filePath);
65
+ fs.mkdirSync(dir, { recursive: true });
66
+ const tmp = `${this.filePath}.tmp`;
67
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
68
+ fs.renameSync(tmp, this.filePath);
69
+ }
70
+
71
+ private mergeEntry(incoming: RegistryEntry, existing: RegistryEntry | undefined): RegistryEntry {
72
+ if (!existing) return incoming;
73
+ return {
74
+ ...incoming,
75
+ tmuxSession: incoming.tmuxSession || existing.tmuxSession,
76
+ };
77
+ }
78
+
79
+ isAlive(entry: RegistryEntry): boolean {
80
+ try {
81
+ process.kill(entry.pid, 0);
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ prune(): void {
89
+ const data = this.readFile();
90
+ const live = data.entries.filter((e) => this.isAlive(e));
91
+ if (live.length !== data.entries.length) {
92
+ this.writeFile({ entries: live });
93
+ }
94
+ }
95
+
96
+ register(entry: RegistryEntry): void {
97
+ this.registerBatch([entry]);
98
+ }
99
+
100
+ registerBatch(entries: RegistryEntry[]): void {
101
+ if (entries.length === 0) return;
102
+ const data = this.readFile();
103
+ for (const incoming of entries) {
104
+ const idx = data.entries.findIndex((e) => e.name === incoming.name);
105
+ if (idx >= 0) {
106
+ data.entries[idx] = this.mergeEntry(incoming, data.entries[idx]);
107
+ } else {
108
+ data.entries.push(incoming);
109
+ }
110
+ }
111
+ this.writeFile(data);
112
+ }
113
+
114
+ rename(currentName: string, newName: string): void {
115
+ const data = this.readFile();
116
+ const idx = data.entries.findIndex((e) => e.name === currentName);
117
+ if (idx < 0) {
118
+ throw new RenameNotFoundError(currentName);
119
+ }
120
+ const liveEntries = data.entries.filter((e) => this.isAlive(e));
121
+ const conflict = liveEntries.find((e) => e.name === newName);
122
+ if (conflict) {
123
+ throw new RenameConflictError(newName);
124
+ }
125
+ const pruned = liveEntries.map((e) =>
126
+ e.name === currentName ? { ...e, name: newName } : e,
127
+ );
128
+ this.writeFile({ entries: pruned });
129
+ }
130
+
131
+ lookup(name: string): RegistryEntry | null {
132
+ const data = this.readFile();
133
+ return data.entries.find((e) => e.name === name) ?? null;
134
+ }
135
+
136
+ list(): RegistryEntry[] {
137
+ return this.readFile().entries;
138
+ }
139
+ }
@@ -51,8 +51,23 @@ export interface ClaudeSession {
51
51
  firstUserMessage?: string;
52
52
  }
53
53
 
54
- /** Entry types that are metadata, not conversation state. */
55
- const METADATA_ENTRY_TYPES = new Set(['last-prompt', 'file-history-snapshot']);
54
+ /**
55
+ * Top-level JSONL entry types that represent conversation/agent state.
56
+ *
57
+ * Only these types update `lastEntryType` for status determination. All
58
+ * other types Claude Code emits (`attachment`, `permission-mode`,
59
+ * `ai-title`, `queued_command`, `tools_changed`, `model_changed`,
60
+ * `hook_progress`, …) are UI-state events that must not overwrite the
61
+ * last conversation turn — otherwise polling between writes lands on a
62
+ * UI-state entry and `determineStatus` falls through to UNKNOWN.
63
+ */
64
+ const CONVERSATION_ENTRY_TYPES = new Set([
65
+ 'user',
66
+ 'assistant',
67
+ 'system',
68
+ 'progress',
69
+ 'thinking',
70
+ ]);
56
71
 
57
72
  /**
58
73
  * Parses Claude Code session JSONL files into structured data.
@@ -110,7 +125,7 @@ export class ClaudeSessionParser {
110
125
  lastCwd = entry.cwd;
111
126
  }
112
127
 
113
- if (entry.type && !METADATA_ENTRY_TYPES.has(entry.type)) {
128
+ if (entry.type && CONVERSATION_ENTRY_TYPES.has(entry.type)) {
114
129
  lastEntryType = entry.type;
115
130
 
116
131
  if (entry.type === 'user') {
@@ -220,16 +235,12 @@ export class ClaudeSessionParser {
220
235
  continue;
221
236
  }
222
237
 
223
- const entryType = entry.type;
224
- if (!entryType || METADATA_ENTRY_TYPES.has(entryType)) continue;
225
- if (entryType === 'progress' || entryType === 'thinking') continue;
226
-
227
238
  let role: ConversationMessage['role'];
228
- if (entryType === 'user') {
239
+ if (entry.type === 'user') {
229
240
  role = 'user';
230
- } else if (entryType === 'assistant') {
241
+ } else if (entry.type === 'assistant') {
231
242
  role = 'assistant';
232
- } else if (entryType === 'system') {
243
+ } else if (entry.type === 'system') {
233
244
  role = 'system';
234
245
  } else {
235
246
  continue;
@@ -0,0 +1,41 @@
1
+ import path from 'path';
2
+ import type { AgentType } from '../adapters/AgentAdapter.js';
3
+
4
+ export type StartableAgentType = Extract<AgentType, 'claude' | 'codex' | 'gemini_cli' | 'opencode'>;
5
+
6
+ export interface AgentConfig {
7
+ /** Shell command to launch the agent (sent to tmux via `send-keys`). */
8
+ command: string;
9
+ /** Returns true if the given `ps` command line belongs to this agent. */
10
+ matches: (psCommand: string) => boolean;
11
+ }
12
+
13
+ /**
14
+ * Per-agent configuration: launch command plus a matcher that recognizes the
15
+ * agent's process in `ps` output. Each matcher knows that agent's distribution
16
+ * quirks (e.g. gemini ships as a Node script so its real binary is in argv[1..]).
17
+ */
18
+ export const AGENTS: Record<StartableAgentType, AgentConfig> = {
19
+ claude: { command: 'claude', matches: matchArgv0('claude') },
20
+ codex: { command: 'codex', matches: matchArgv0('codex') },
21
+ opencode: { command: 'opencode', matches: matchArgv0('opencode') },
22
+ gemini_cli: { command: 'gemini', matches: matchAnyToken('gemini') },
23
+ };
24
+
25
+ function matchArgv0(name: string): (psCommand: string) => boolean {
26
+ const lower = name.toLowerCase();
27
+ return (psCommand) => {
28
+ const token = psCommand.trim().split(/\s+/)[0];
29
+ return token ? path.basename(token).toLowerCase() === lower : false;
30
+ };
31
+ }
32
+
33
+ function matchAnyToken(name: string): (psCommand: string) => boolean {
34
+ const lower = name.toLowerCase();
35
+ return (psCommand) => {
36
+ for (const token of psCommand.trim().split(/\s+/)) {
37
+ if (path.basename(token).toLowerCase() === lower) return true;
38
+ }
39
+ return false;
40
+ };
41
+ }