@ai-devkit/agent-manager 0.10.0 → 0.12.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 (57) hide show
  1. package/dist/AgentManager.d.ts +14 -1
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +38 -0
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/adapters/AgentAdapter.d.ts +66 -0
  6. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  7. package/dist/adapters/ClaudeCodeAdapter.d.ts +22 -1
  8. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  9. package/dist/adapters/ClaudeCodeAdapter.js +108 -4
  10. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  11. package/dist/adapters/CodexAdapter.d.ts +14 -1
  12. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  13. package/dist/adapters/CodexAdapter.js +105 -6
  14. package/dist/adapters/CodexAdapter.js.map +1 -1
  15. package/dist/adapters/GeminiCliAdapter.d.ts +9 -1
  16. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  17. package/dist/adapters/GeminiCliAdapter.js +77 -6
  18. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
  23. package/dist/terminal/TerminalFocusManager.js +5 -7
  24. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  25. package/dist/terminal/TtyWriter.d.ts.map +1 -1
  26. package/dist/terminal/TtyWriter.js +3 -12
  27. package/dist/terminal/TtyWriter.js.map +1 -1
  28. package/dist/utils/ClaudeSessionParser.d.ts +2 -0
  29. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
  30. package/dist/utils/ClaudeSessionParser.js +48 -5
  31. package/dist/utils/ClaudeSessionParser.js.map +1 -1
  32. package/dist/utils/applescript.d.ts +6 -0
  33. package/dist/utils/applescript.d.ts.map +1 -0
  34. package/dist/utils/applescript.js +14 -0
  35. package/dist/utils/applescript.js.map +1 -0
  36. package/dist/utils/session.d.ts +34 -5
  37. package/dist/utils/session.d.ts.map +1 -1
  38. package/dist/utils/session.js +90 -44
  39. package/dist/utils/session.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/AgentManager.ts +55 -3
  42. package/src/__tests__/AgentManager.test.ts +134 -2
  43. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +229 -3
  44. package/src/__tests__/adapters/CodexAdapter.test.ts +123 -3
  45. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +133 -0
  46. package/src/__tests__/utils/ClaudeSessionParser.test.ts +195 -0
  47. package/src/__tests__/utils/session.test.ts +79 -43
  48. package/src/adapters/AgentAdapter.ts +76 -0
  49. package/src/adapters/ClaudeCodeAdapter.ts +136 -6
  50. package/src/adapters/CodexAdapter.ts +126 -8
  51. package/src/adapters/GeminiCliAdapter.ts +102 -7
  52. package/src/index.ts +9 -1
  53. package/src/terminal/TerminalFocusManager.ts +1 -4
  54. package/src/terminal/TtyWriter.ts +1 -11
  55. package/src/utils/ClaudeSessionParser.ts +59 -5
  56. package/src/utils/applescript.ts +10 -0
  57. package/src/utils/session.ts +86 -45
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tests for utils/ClaudeSessionParser.ts — focused on stripping
3
+ * harness-injected XML tags from conversation content.
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
7
+ import * as fs from 'fs';
8
+ import * as os from 'os';
9
+ import * as path from 'path';
10
+ import { ClaudeSessionParser } from '../../utils/ClaudeSessionParser';
11
+
12
+ interface JsonlEntry {
13
+ type: 'user' | 'assistant' | 'system';
14
+ message: { content: string | Array<{ type: string; text?: string; content?: string; name?: string; input?: unknown; is_error?: boolean }> };
15
+ timestamp?: string;
16
+ }
17
+
18
+ function writeSession(entries: JsonlEntry[]): string {
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'parser-test-'));
20
+ const filePath = path.join(dir, 'session.jsonl');
21
+ fs.writeFileSync(filePath, entries.map(e => JSON.stringify(e)).join('\n'));
22
+ return filePath;
23
+ }
24
+
25
+ describe('ClaudeSessionParser.getConversation — harness tag stripping', () => {
26
+ let parser: ClaudeSessionParser;
27
+ const tempFiles: string[] = [];
28
+
29
+ beforeEach(() => {
30
+ parser = new ClaudeSessionParser();
31
+ });
32
+
33
+ afterEach(() => {
34
+ for (const f of tempFiles) {
35
+ try {
36
+ fs.rmSync(path.dirname(f), { recursive: true, force: true });
37
+ } catch { /* best effort */ }
38
+ }
39
+ tempFiles.length = 0;
40
+ });
41
+
42
+ function makeSession(entries: JsonlEntry[]): string {
43
+ const f = writeSession(entries);
44
+ tempFiles.push(f);
45
+ return f;
46
+ }
47
+
48
+ it('strips <system-reminder> blocks from text content', () => {
49
+ const file = makeSession([
50
+ {
51
+ type: 'assistant',
52
+ message: {
53
+ content: [{
54
+ type: 'text',
55
+ text: 'Real response here.\n<system-reminder>\nDo not mention this reminder.\n</system-reminder>\nMore response.',
56
+ }],
57
+ },
58
+ },
59
+ ]);
60
+
61
+ const conv = parser.getConversation(file);
62
+ expect(conv).toHaveLength(1);
63
+ expect(conv[0].content).toBe('Real response here.\n\nMore response.');
64
+ });
65
+
66
+ it('strips <local-command-stdout> blocks', () => {
67
+ const file = makeSession([
68
+ {
69
+ type: 'system',
70
+ message: { content: 'before <local-command-stdout>\nRunning...\nDone\n</local-command-stdout> after' },
71
+ },
72
+ ]);
73
+
74
+ const conv = parser.getConversation(file);
75
+ expect(conv[0].content).toBe('before after');
76
+ });
77
+
78
+ it('strips <user-prompt-submit-hook> blocks', () => {
79
+ const file = makeSession([
80
+ {
81
+ type: 'user',
82
+ message: { content: '<user-prompt-submit-hook>hook output</user-prompt-submit-hook>\nactual question' },
83
+ },
84
+ ]);
85
+
86
+ const conv = parser.getConversation(file);
87
+ expect(conv[0].content).toBe('actual question');
88
+ });
89
+
90
+ it('strips bash and command stdout/stderr blocks', () => {
91
+ const file = makeSession([
92
+ {
93
+ type: 'assistant',
94
+ message: {
95
+ content: [{
96
+ type: 'text',
97
+ text: 'Output:\n<bash-input>ls</bash-input>\n<bash-stdout>file.txt</bash-stdout>\n<bash-stderr></bash-stderr>\n<command-stdout>x</command-stdout>\n<command-stderr>y</command-stderr>\nEnd.',
98
+ }],
99
+ },
100
+ },
101
+ ]);
102
+
103
+ const conv = parser.getConversation(file);
104
+ expect(conv[0].content).toBe('Output:\n\n\n\n\n\nEnd.'.replace(/\n{3,}/g, '\n\n'));
105
+ expect(conv[0].content).not.toMatch(/<bash-/);
106
+ expect(conv[0].content).not.toMatch(/<command-stdout>|<command-stderr>/);
107
+ });
108
+
109
+ it('collapses <command-name>/<command-args> into "/name args" shorthand', () => {
110
+ const file = makeSession([
111
+ {
112
+ type: 'user',
113
+ message: {
114
+ content: '<command-message>debug</command-message>\n<command-name>/debug</command-name>\n<command-args>fix the bug</command-args>',
115
+ },
116
+ },
117
+ ]);
118
+
119
+ const conv = parser.getConversation(file);
120
+ expect(conv[0].content).toBe('/debug fix the bug');
121
+ });
122
+
123
+ it('handles <command-name> without <command-args>', () => {
124
+ const file = makeSession([
125
+ {
126
+ type: 'user',
127
+ message: {
128
+ content: '<command-message>clear</command-message>\n<command-name>/clear</command-name>',
129
+ },
130
+ },
131
+ ]);
132
+
133
+ const conv = parser.getConversation(file);
134
+ expect(conv[0].content).toBe('/clear');
135
+ });
136
+
137
+ it('handles multiple harness tags mixed together', () => {
138
+ const file = makeSession([
139
+ {
140
+ type: 'user',
141
+ message: {
142
+ content: [{
143
+ type: 'text',
144
+ text: '<system-reminder>ignore me</system-reminder>\n<command-message>build</command-message>\n<command-name>/build</command-name>\n<command-args>--watch</command-args>\n<local-command-stdout>build output here</local-command-stdout>',
145
+ }],
146
+ },
147
+ },
148
+ ]);
149
+
150
+ const conv = parser.getConversation(file);
151
+ expect(conv[0].content).toBe('/build --watch');
152
+ });
153
+
154
+ it('leaves text without harness tags unchanged', () => {
155
+ const file = makeSession([
156
+ {
157
+ type: 'assistant',
158
+ message: { content: [{ type: 'text', text: 'Hello, world!' }] },
159
+ },
160
+ ]);
161
+
162
+ const conv = parser.getConversation(file);
163
+ expect(conv[0].content).toBe('Hello, world!');
164
+ });
165
+
166
+ it('drops a message that becomes empty after stripping', () => {
167
+ const file = makeSession([
168
+ {
169
+ type: 'system',
170
+ message: { content: '<system-reminder>only this</system-reminder>' },
171
+ },
172
+ {
173
+ type: 'assistant',
174
+ message: { content: [{ type: 'text', text: 'kept' }] },
175
+ },
176
+ ]);
177
+
178
+ const conv = parser.getConversation(file);
179
+ expect(conv).toHaveLength(1);
180
+ expect(conv[0].content).toBe('kept');
181
+ });
182
+
183
+ it('strips tags spanning multiple lines', () => {
184
+ const multilineReminder = '<system-reminder>\nLine 1\nLine 2\nLine 3\n</system-reminder>';
185
+ const file = makeSession([
186
+ {
187
+ type: 'assistant',
188
+ message: { content: [{ type: 'text', text: `before\n${multilineReminder}\nafter` }] },
189
+ },
190
+ ]);
191
+
192
+ const conv = parser.getConversation(file);
193
+ expect(conv[0].content).toBe('before\n\nafter');
194
+ });
195
+ });
@@ -3,25 +3,31 @@
3
3
  */
4
4
 
5
5
  import { describe, it, expect, jest, beforeEach } from '@jest/globals';
6
- import { execSync } from 'child_process';
6
+ import * as fs from 'fs';
7
7
  import { batchGetSessionFileBirthtimes } from '../../utils/session';
8
8
 
9
- jest.mock('child_process', () => ({
10
- execSync: jest.fn(),
9
+ jest.mock('fs', () => ({
10
+ readdirSync: jest.fn(),
11
+ statSync: jest.fn(),
11
12
  }));
12
13
 
13
- const mockedExecSync = execSync as jest.MockedFunction<typeof execSync>;
14
+ const mockedReaddirSync = fs.readdirSync as jest.MockedFunction<typeof fs.readdirSync>;
15
+ const mockedStatSync = fs.statSync as jest.MockedFunction<typeof fs.statSync>;
14
16
 
15
17
  describe('batchGetSessionFileBirthtimes', () => {
16
18
  beforeEach(() => {
17
- mockedExecSync.mockReset();
19
+ mockedReaddirSync.mockReset();
20
+ mockedStatSync.mockReset();
18
21
  });
19
22
 
20
- it('should parse stat output correctly', () => {
21
- mockedExecSync.mockReturnValue(
22
- '1710800324 /home/.claude/projects/my-app/abc123.jsonl\n' +
23
- '1710800500 /home/.claude/projects/my-app/def456.jsonl\n',
24
- );
23
+ it('should parse session files correctly', () => {
24
+ mockedReaddirSync.mockReturnValue([
25
+ 'abc123.jsonl',
26
+ 'def456.jsonl',
27
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
28
+ mockedStatSync
29
+ .mockReturnValueOnce({ birthtimeMs: 1710800324000 } as fs.Stats)
30
+ .mockReturnValueOnce({ birthtimeMs: 1710800500000 } as fs.Stats);
25
31
 
26
32
  const results = batchGetSessionFileBirthtimes(['/home/.claude/projects/my-app']);
27
33
 
@@ -39,49 +45,57 @@ describe('batchGetSessionFileBirthtimes', () => {
39
45
 
40
46
  it('should return empty array for empty dirs list', () => {
41
47
  expect(batchGetSessionFileBirthtimes([])).toEqual([]);
42
- expect(mockedExecSync).not.toHaveBeenCalled();
48
+ expect(mockedReaddirSync).not.toHaveBeenCalled();
43
49
  });
44
50
 
45
- it('should return empty array on command failure', () => {
46
- mockedExecSync.mockImplementation(() => {
47
- throw new Error('Command failed');
51
+ it('should return empty array on readdir failure', () => {
52
+ mockedReaddirSync.mockImplementation(() => {
53
+ throw new Error('ENOENT');
48
54
  });
49
55
 
50
56
  expect(batchGetSessionFileBirthtimes(['/some/dir'])).toEqual([]);
51
57
  });
52
58
 
53
- it('should skip lines with invalid epoch (0 or negative)', () => {
54
- mockedExecSync.mockReturnValue(
55
- '0 /dir/bad.jsonl\n' +
56
- '-1 /dir/negative.jsonl\n' +
57
- '1710800324 /dir/good.jsonl\n',
58
- );
59
+ it('should skip files with invalid birthtime (0 or negative)', () => {
60
+ mockedReaddirSync.mockReturnValue([
61
+ 'bad.jsonl',
62
+ 'negative.jsonl',
63
+ 'good.jsonl',
64
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
65
+ mockedStatSync
66
+ .mockReturnValueOnce({ birthtimeMs: 0 } as fs.Stats)
67
+ .mockReturnValueOnce({ birthtimeMs: -1 } as fs.Stats)
68
+ .mockReturnValueOnce({ birthtimeMs: 1710800324000 } as fs.Stats);
59
69
 
60
70
  const results = batchGetSessionFileBirthtimes(['/dir']);
61
71
  expect(results).toHaveLength(1);
62
72
  expect(results[0].sessionId).toBe('good');
63
73
  });
64
74
 
65
- it('should skip non-jsonl files in output', () => {
66
- mockedExecSync.mockReturnValue(
67
- '1710800324 /dir/sessions-index.json\n' +
68
- '1710800500 /dir/abc123.jsonl\n',
69
- );
75
+ it('should skip non-jsonl files', () => {
76
+ mockedReaddirSync.mockReturnValue([
77
+ 'sessions-index.json',
78
+ 'abc123.jsonl',
79
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
80
+ mockedStatSync
81
+ .mockReturnValueOnce({ birthtimeMs: 1710800500000 } as fs.Stats);
70
82
 
71
83
  const results = batchGetSessionFileBirthtimes(['/dir']);
72
84
  expect(results).toHaveLength(1);
73
85
  expect(results[0].sessionId).toBe('abc123');
74
86
  });
75
87
 
76
- it('should handle empty output', () => {
77
- mockedExecSync.mockReturnValue('');
88
+ it('should handle empty directory', () => {
89
+ mockedReaddirSync.mockReturnValue([] as unknown as ReturnType<typeof fs.readdirSync>);
78
90
  expect(batchGetSessionFileBirthtimes(['/dir'])).toEqual([]);
79
91
  });
80
92
 
81
93
  it('should handle UUID session IDs', () => {
82
- mockedExecSync.mockReturnValue(
83
- '1710800324 /dir/068e7b1f-cff5-4c94-bf69-b9acd32d765c.jsonl\n',
84
- );
94
+ mockedReaddirSync.mockReturnValue([
95
+ '068e7b1f-cff5-4c94-bf69-b9acd32d765c.jsonl',
96
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
97
+ mockedStatSync
98
+ .mockReturnValueOnce({ birthtimeMs: 1710800324000 } as fs.Stats);
85
99
 
86
100
  const results = batchGetSessionFileBirthtimes(['/dir']);
87
101
  expect(results).toHaveLength(1);
@@ -89,29 +103,51 @@ describe('batchGetSessionFileBirthtimes', () => {
89
103
  });
90
104
 
91
105
  it('should leave resolvedCwd empty', () => {
92
- mockedExecSync.mockReturnValue('1710800324 /dir/abc.jsonl\n');
106
+ mockedReaddirSync.mockReturnValue([
107
+ 'abc.jsonl',
108
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
109
+ mockedStatSync
110
+ .mockReturnValueOnce({ birthtimeMs: 1710800324000 } as fs.Stats);
93
111
 
94
112
  const results = batchGetSessionFileBirthtimes(['/dir']);
95
113
  expect(results[0].resolvedCwd).toBe('');
96
114
  });
97
115
 
98
- it('should combine multiple directories into a single stat call', () => {
99
- mockedExecSync.mockReturnValue(
100
- '1710800324 /projects/app-a/sess1.jsonl\n' +
101
- '1710800400 /projects/app-b/sess2.jsonl\n' +
102
- '1710800500 /projects/app-a/sess3.jsonl\n',
103
- );
116
+ it('should enumerate multiple directories', () => {
117
+ mockedReaddirSync
118
+ .mockReturnValueOnce([
119
+ 'sess1.jsonl',
120
+ 'sess3.jsonl',
121
+ ] as unknown as ReturnType<typeof fs.readdirSync>)
122
+ .mockReturnValueOnce([
123
+ 'sess2.jsonl',
124
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
125
+ mockedStatSync
126
+ .mockReturnValueOnce({ birthtimeMs: 1710800324000 } as fs.Stats)
127
+ .mockReturnValueOnce({ birthtimeMs: 1710800500000 } as fs.Stats)
128
+ .mockReturnValueOnce({ birthtimeMs: 1710800400000 } as fs.Stats);
104
129
 
105
130
  const results = batchGetSessionFileBirthtimes(['/projects/app-a', '/projects/app-b']);
106
131
 
107
- expect(mockedExecSync).toHaveBeenCalledTimes(1);
108
- const cmd = mockedExecSync.mock.calls[0][0] as string;
109
- expect(cmd).toContain('"/projects/app-a"/*.jsonl');
110
- expect(cmd).toContain('"/projects/app-b"/*.jsonl');
132
+ expect(mockedReaddirSync).toHaveBeenCalledTimes(2);
111
133
 
112
134
  expect(results).toHaveLength(3);
113
135
  expect(results[0].projectDir).toBe('/projects/app-a');
114
- expect(results[1].projectDir).toBe('/projects/app-b');
115
- expect(results[2].projectDir).toBe('/projects/app-a');
136
+ expect(results[1].projectDir).toBe('/projects/app-a');
137
+ expect(results[2].projectDir).toBe('/projects/app-b');
138
+ });
139
+
140
+ it('should skip files where statSync fails', () => {
141
+ mockedReaddirSync.mockReturnValue([
142
+ 'good.jsonl',
143
+ 'gone.jsonl',
144
+ ] as unknown as ReturnType<typeof fs.readdirSync>);
145
+ mockedStatSync
146
+ .mockReturnValueOnce({ birthtimeMs: 1710800324000 } as fs.Stats)
147
+ .mockImplementationOnce(() => { throw new Error('ENOENT'); });
148
+
149
+ const results = batchGetSessionFileBirthtimes(['/dir']);
150
+ expect(results).toHaveLength(1);
151
+ expect(results[0].sessionId).toBe('good');
116
152
  });
117
153
  });
@@ -81,6 +81,70 @@ export interface ConversationMessage {
81
81
  timestamp?: string;
82
82
  }
83
83
 
84
+ /**
85
+ * A historical session discovered on disk (running or not).
86
+ *
87
+ * Used by `listSessions` to surface enough context for a user to identify
88
+ * a session and resume it via the originating tool's resume command.
89
+ */
90
+ export interface SessionSummary {
91
+ /** Tool that produced this session */
92
+ type: AgentType;
93
+
94
+ /**
95
+ * ID accepted by the tool's resume command. Adapters MUST pass this
96
+ * through verbatim — no normalization, no encoding/decoding — so it
97
+ * round-trips into `claude --resume <id>` (and equivalents).
98
+ */
99
+ sessionId: string;
100
+
101
+ /** Working directory the session was started in (best-known value) */
102
+ cwd: string;
103
+
104
+ /**
105
+ * Trimmed first user message; empty string if none. Adapters apply
106
+ * the same noise-filter their existing parsers use (skip tool_result
107
+ * blocks, request-interruption notices, system-injected skill
108
+ * content). The CLI table renderer substitutes a placeholder for
109
+ * empty values; JSON output keeps the empty string raw.
110
+ */
111
+ firstUserMessage: string;
112
+
113
+ /** Last activity timestamp (from session content; falls back to file mtime) */
114
+ lastActive: Date;
115
+
116
+ /** Session start time (from session content; falls back to file birthtime/mtime) */
117
+ startedAt: Date;
118
+
119
+ /** Absolute path to the session file on disk (debug/diagnostics) */
120
+ sessionFilePath: string;
121
+ }
122
+
123
+ /**
124
+ * Filters passed by the CLI to {@link AgentAdapter.listSessions}.
125
+ *
126
+ * The CLI is the source of truth for filter defaults and semantics
127
+ * (e.g. cwd defaults to process.cwd(); --all clears it). Adapters apply
128
+ * the values they receive — they don't invent defaults.
129
+ */
130
+ export interface ListSessionsOptions {
131
+ /**
132
+ * Filter to sessions whose recorded cwd matches this path using strict
133
+ * equality (no prefix/ancestor matching in v1). Undefined = no cwd
134
+ * filter.
135
+ */
136
+ cwd?: string;
137
+
138
+ /**
139
+ * Filter to a single tool. Enforced by `AgentManager.listSessions`,
140
+ * which skips adapters whose `type` doesn't match. Adapters MAY
141
+ * ignore this field — by the time their `listSessions` runs, the
142
+ * type filter is already satisfied. Undefined = include every
143
+ * registered adapter.
144
+ */
145
+ type?: AgentType;
146
+ }
147
+
84
148
  /**
85
149
  * Agent Adapter Interface
86
150
  *
@@ -110,4 +174,16 @@ export interface AgentAdapter {
110
174
  * @returns Array of conversation messages
111
175
  */
112
176
  getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[];
177
+
178
+ /**
179
+ * Enumerate historical sessions for this tool from disk.
180
+ *
181
+ * Applies `opts.cwd` as a strict-equality filter when set. Returns
182
+ * {@link SessionSummary} entries unsorted; sorting and global filters
183
+ * are handled by `AgentManager` and the CLI.
184
+ *
185
+ * @param opts Filter options computed by the CLI
186
+ * @returns Array of sessions discovered on disk
187
+ */
188
+ listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]>;
113
189
  }
@@ -1,9 +1,16 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter';
3
+ import type {
4
+ AgentAdapter,
5
+ AgentInfo,
6
+ ProcessInfo,
7
+ ConversationMessage,
8
+ SessionSummary,
9
+ ListSessionsOptions,
10
+ } from './AgentAdapter';
4
11
  import { AgentStatus } from './AgentAdapter';
5
12
  import { listAgentProcesses, enrichProcesses } from '../utils/process';
6
- import { batchGetSessionFileBirthtimes } from '../utils/session';
13
+ import { batchGetSessionFileBirthtimes, isDirectory, listJsonl, safeReaddir, safeStat } from '../utils/session';
7
14
  import type { SessionFile } from '../utils/session';
8
15
  import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
9
16
  import { ClaudeSessionParser } from '../utils/ClaudeSessionParser';
@@ -74,10 +81,17 @@ export class ClaudeCodeAdapter implements AgentAdapter {
74
81
  return [];
75
82
  }
76
83
 
77
- // Step 1: try authoritative PID-file matching for every process
78
- const { direct, fallback } = this.tryPidFileMatching(processes);
84
+ // Step 1: extract `--resume <id>` from command line — authoritative for
85
+ // resumed sessions where the JSONL predates the process and PID-file/
86
+ // birthtime heuristics can't match it.
87
+ const { direct: resumeDirect, fallback: noResume } = this.tryResumeMatching(processes);
79
88
 
80
- // Step 2: run legacy CWD+birthtime matching only for processes without a PID file
89
+ // Step 2: try authoritative PID-file matching for the rest
90
+ const { direct: pidDirect, fallback } = this.tryPidFileMatching(noResume);
91
+
92
+ const direct = [...resumeDirect, ...pidDirect];
93
+
94
+ // Step 3: run legacy CWD+birthtime matching only for processes without a PID file
81
95
  const legacySessions = this.discoverSessions(fallback);
82
96
  const legacyMatches =
83
97
  fallback.length > 0 && legacySessions.length > 0
@@ -91,7 +105,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
91
105
 
92
106
  const agents: AgentInfo[] = [];
93
107
 
94
- // Build agents from direct (PID-file) matches
108
+ // Build agents from direct (resume + PID-file) matches
95
109
  for (const { process: proc, sessionFile } of direct) {
96
110
  const sessionData = this.parser.readSession(sessionFile.filePath, sessionFile.resolvedCwd);
97
111
  if (sessionData) {
@@ -160,6 +174,55 @@ export class ClaudeCodeAdapter implements AgentAdapter {
160
174
  return files;
161
175
  }
162
176
 
177
+ /**
178
+ * Match processes via `claude --resume <uuid>` in their command line.
179
+ * This works for resumed sessions, where the JSONL was created earlier
180
+ * (so its birthtime is far from the process startTime and the legacy
181
+ * matcher can't pair them) and the PID file may also be misaligned.
182
+ */
183
+ private tryResumeMatching(processes: ProcessInfo[]): {
184
+ direct: DirectMatch[];
185
+ fallback: ProcessInfo[];
186
+ } {
187
+ const direct: DirectMatch[] = [];
188
+ const fallback: ProcessInfo[] = [];
189
+
190
+ for (const proc of processes) {
191
+ const sessionId = this.extractResumeSessionId(proc.command);
192
+ if (!sessionId || !proc.cwd) {
193
+ fallback.push(proc);
194
+ continue;
195
+ }
196
+
197
+ const projectDir = this.getProjectDir(proc.cwd);
198
+ const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`);
199
+
200
+ const stat = safeStat(jsonlPath);
201
+ if (!stat) {
202
+ fallback.push(proc);
203
+ continue;
204
+ }
205
+
206
+ direct.push({
207
+ process: proc,
208
+ sessionFile: {
209
+ sessionId,
210
+ filePath: jsonlPath,
211
+ projectDir,
212
+ birthtimeMs: stat.birthtimeMs,
213
+ resolvedCwd: proc.cwd,
214
+ },
215
+ });
216
+ }
217
+
218
+ return { direct, fallback };
219
+ }
220
+
221
+ private extractResumeSessionId(command: string): string | null {
222
+ const match = command.match(/--resume\s+([0-9a-f-]{36})/i);
223
+ return match?.[1] ?? null;
224
+ }
225
+
163
226
  /**
164
227
  * Attempt to match each process to its session via ~/.claude/sessions/<pid>.json.
165
228
  *
@@ -265,4 +328,71 @@ export class ClaudeCodeAdapter implements AgentAdapter {
265
328
  getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
266
329
  return this.parser.getConversation(sessionFilePath, options);
267
330
  }
331
+
332
+ async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
333
+ const filterCwd = opts?.cwd;
334
+ const candidates = this.discoverSessionFiles();
335
+ const summaries: SessionSummary[] = [];
336
+
337
+ for (const { filePath, defaultCwd } of candidates) {
338
+ const session = this.parser.readSession(filePath, defaultCwd);
339
+ if (!session) continue;
340
+
341
+ // Drop sessions whose JSONL had no parseable conversation entries.
342
+ // readSession is permissive (returns a shell record even when every
343
+ // line fails to parse); listSessions needs at least one real entry
344
+ // so we don't surface garbage files.
345
+ if (!session.lastEntryType) continue;
346
+
347
+ const recordedCwd = session.lastCwd || defaultCwd;
348
+ if (filterCwd !== undefined && recordedCwd !== filterCwd) continue;
349
+
350
+ const stat = safeStat(filePath);
351
+
352
+ summaries.push({
353
+ type: 'claude',
354
+ sessionId: session.sessionId,
355
+ cwd: recordedCwd,
356
+ firstUserMessage: session.firstUserMessage || '',
357
+ lastActive: session.lastActive ?? stat?.mtime ?? new Date(),
358
+ startedAt: session.sessionStart ?? stat?.birthtime ?? stat?.mtime ?? new Date(),
359
+ sessionFilePath: filePath,
360
+ });
361
+ }
362
+
363
+ return summaries;
364
+ }
365
+
366
+ /**
367
+ * Discover candidate session files for {@link listSessions}.
368
+ *
369
+ * Always walks every subdirectory of `projectsDir`. We can't use the
370
+ * encoded-dir shortcut for the cwd-scoped path because Claude Code
371
+ * indexes session files by where the *process was launched*, not by
372
+ * the recorded `cwd` field inside the session — these diverge in
373
+ * worktrees and similar setups. The cwd filter is applied later
374
+ * against `session.lastCwd` so callers see exactly the sessions whose
375
+ * recorded cwd matches.
376
+ */
377
+ private discoverSessionFiles(): Array<{ filePath: string; defaultCwd: string }> {
378
+ const out: Array<{ filePath: string; defaultCwd: string }> = [];
379
+
380
+ if (!isDirectory(this.projectsDir)) return out;
381
+
382
+ for (const dirName of safeReaddir(this.projectsDir)) {
383
+ const projectDir = path.join(this.projectsDir, dirName);
384
+ if (!isDirectory(projectDir)) continue;
385
+
386
+ // Best-effort decode for the rare case session content has no
387
+ // recorded cwd: '-Users-foo-bar' → '/Users/foo/bar'. Lossy for
388
+ // paths containing '-'; session content's lastCwd overrides
389
+ // this when available.
390
+ const decoded = dirName.replace(/-/g, '/');
391
+ for (const name of listJsonl(projectDir)) {
392
+ out.push({ filePath: path.join(projectDir, name), defaultCwd: decoded });
393
+ }
394
+ }
395
+
396
+ return out;
397
+ }
268
398
  }