Skip to content

Commit 1a52983

Browse files
committed
fix(agent-manager): prefer exact history cwd for claude pid-session mapping
1 parent 88fb786 commit 1a52983

File tree

3 files changed

+422
-58
lines changed

3 files changed

+422
-58
lines changed

docs/ai/design/feature-agent-management.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ const STATUS_CONFIG = {
119119
interface ClaudeCodeSession {
120120
sessionId: string; // UUID from session filename
121121
projectPath: string; // Original project path (from sessions-index.json)
122+
lastCwd?: string; // Last cwd seen in session entries (when available)
122123
slug: string; // Human-readable name (e.g., "merry-wobbling-starlight")
123124
sessionLogPath: string; // Path to the .jsonl session file
124125
debugLogPath?: string; // Path to the debug log file
@@ -147,12 +148,13 @@ interface HistoryEntry {
147148
1. **Process Detection**: Query running processes (`ps aux | grep claude`) → List of PIDs + TTYs
148149
2. **Session Discovery**: Read `~/.claude/projects/*/sessions-index.json` → List of sessions with project paths
149150
3. **Session-Process Correlation**:
150-
- Group running processes by CWD (project path)
151-
- Group available sessions by project path
152-
- **Duplicate Filtering**: If multiple sessions match a project path:
153-
- Sort sessions by last active time (newest first)
154-
- Take the top N sessions, where N is the number of active processes for that path
155-
- Strictly map active processes to these N sessions to avoid "ghost" agents
151+
- Running Claude processes are source-of-truth for membership
152+
- Correlation priority for each process:
153+
- **Phase 1 (`cwd`)**: Exact match with session `lastCwd` or `projectPath`
154+
- **Phase 2 (`history-cwd`)**: Exact match with `history.jsonl` where `history.project === process.cwd`
155+
- **Phase 3 (`project-parent`)**: Process cwd is child of session `projectPath` or `lastCwd`
156+
- **Phase 4 (`process-only`)**: Emit process-only agent when no session match exists
157+
- This prevents dropped Claude processes when transcripts lag or when process cwd is a subdirectory (e.g. `packages/cli`)
156158
4. **Terminal Location**: For each matched process, find terminal location:
157159
- Get TTY from PID: `ps -p {PID} -o tty=`
158160
- Query tmux: `tmux list-panes -a -F '#{pane_tty} #{session}:#{window}.#{pane}'`
@@ -164,6 +166,7 @@ interface HistoryEntry {
164166
- `progress` or `thinking` → running
165167
- `system` or old timestamp → idle
166168
6. **Summary Extraction**: Read `~/.claude/history.jsonl` → Get last user prompt for each session
169+
- For history-cwd fallback and process-only fallback, history entry also provides `sessionId` and `lastActive`
167170
7. **Agent Naming**:
168171
- Use project basename (e.g., "ai-devkit")
169172
- If `slug` exists in session, use for disambiguation (e.g., "ai-devkit (merry)")

packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ describe('ClaudeCodeAdapter', () => {
127127
expect(agents[0].summary).toContain('Investigate failing tests in package');
128128
});
129129

130-
it('should return empty list when process cwd has no matching session', async () => {
130+
it('should include process-only entry when process cwd has no matching session', async () => {
131131
mockedListProcesses.mockReturnValue([
132132
{
133133
pid: 777,
@@ -148,7 +148,125 @@ describe('ClaudeCodeAdapter', () => {
148148
jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([]);
149149

150150
const agents = await adapter.detectAgents();
151-
expect(agents).toEqual([]);
151+
expect(agents).toHaveLength(1);
152+
expect(agents[0]).toMatchObject({
153+
type: 'claude',
154+
status: AgentStatus.RUNNING,
155+
pid: 777,
156+
projectPath: '/project/without-session',
157+
sessionId: 'pid-777',
158+
summary: 'Claude process running',
159+
});
160+
});
161+
162+
it('should match process in subdirectory to project-root session', async () => {
163+
mockedListProcesses.mockReturnValue([
164+
{
165+
pid: 888,
166+
command: 'claude',
167+
cwd: '/Users/test/my-project/packages/cli',
168+
tty: 'ttys009',
169+
},
170+
]);
171+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
172+
{
173+
sessionId: 'session-3',
174+
projectPath: '/Users/test/my-project',
175+
sessionLogPath: '/mock/path/session-3.jsonl',
176+
slug: 'gentle-otter',
177+
lastEntry: { type: 'assistant' },
178+
lastActive: new Date(),
179+
},
180+
]);
181+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([
182+
{
183+
display: 'Refactor CLI command flow',
184+
timestamp: Date.now(),
185+
project: '/Users/test/my-project',
186+
sessionId: 'session-3',
187+
},
188+
]);
189+
190+
const agents = await adapter.detectAgents();
191+
expect(agents).toHaveLength(1);
192+
expect(agents[0]).toMatchObject({
193+
type: 'claude',
194+
pid: 888,
195+
sessionId: 'session-3',
196+
projectPath: '/Users/test/my-project',
197+
summary: 'Refactor CLI command flow',
198+
});
199+
});
200+
201+
it('should use latest history entry for process-only fallback session id', async () => {
202+
mockedListProcesses.mockReturnValue([
203+
{
204+
pid: 97529,
205+
command: 'claude',
206+
cwd: '/Users/test/my-project/packages/cli',
207+
tty: 'ttys021',
208+
},
209+
]);
210+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([]);
211+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([
212+
{
213+
display: '/status',
214+
timestamp: 1772122701536,
215+
project: '/Users/test/my-project/packages/cli',
216+
sessionId: '69237415-b0c3-4990-ba53-15882616509e',
217+
},
218+
]);
219+
220+
const agents = await adapter.detectAgents();
221+
expect(agents).toHaveLength(1);
222+
expect(agents[0]).toMatchObject({
223+
type: 'claude',
224+
pid: 97529,
225+
projectPath: '/Users/test/my-project/packages/cli',
226+
sessionId: '69237415-b0c3-4990-ba53-15882616509e',
227+
summary: '/status',
228+
status: AgentStatus.RUNNING,
229+
});
230+
expect(agents[0].lastActive.toISOString()).toBe('2026-02-26T16:18:21.536Z');
231+
});
232+
233+
it('should prefer exact-cwd history session over parent-project session match', async () => {
234+
mockedListProcesses.mockReturnValue([
235+
{
236+
pid: 97529,
237+
command: 'claude',
238+
cwd: '/Users/test/my-project/packages/cli',
239+
tty: 'ttys021',
240+
},
241+
]);
242+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readSessions').mockReturnValue([
243+
{
244+
sessionId: 'old-parent-session',
245+
projectPath: '/Users/test/my-project',
246+
sessionLogPath: '/mock/path/old-parent-session.jsonl',
247+
slug: 'fluffy-brewing-kazoo',
248+
lastEntry: { type: 'assistant' },
249+
lastActive: new Date('2026-02-23T17:24:50.996Z'),
250+
},
251+
]);
252+
jest.spyOn(adapter as unknown as AdapterPrivates, 'readHistory').mockReturnValue([
253+
{
254+
display: '/status',
255+
timestamp: 1772122701536,
256+
project: '/Users/test/my-project/packages/cli',
257+
sessionId: '69237415-b0c3-4990-ba53-15882616509e',
258+
},
259+
]);
260+
261+
const agents = await adapter.detectAgents();
262+
expect(agents).toHaveLength(1);
263+
expect(agents[0]).toMatchObject({
264+
type: 'claude',
265+
pid: 97529,
266+
sessionId: '69237415-b0c3-4990-ba53-15882616509e',
267+
projectPath: '/Users/test/my-project/packages/cli',
268+
summary: '/status',
269+
});
152270
});
153271
});
154272

0 commit comments

Comments
 (0)