@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.
- package/dist/AgentManager.d.ts +14 -1
- package/dist/AgentManager.d.ts.map +1 -1
- package/dist/AgentManager.js +38 -0
- package/dist/AgentManager.js.map +1 -1
- package/dist/adapters/AgentAdapter.d.ts +66 -0
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +22 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +108 -4
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +14 -1
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +105 -6
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/adapters/GeminiCliAdapter.d.ts +9 -1
- package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
- package/dist/adapters/GeminiCliAdapter.js +77 -6
- package/dist/adapters/GeminiCliAdapter.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
- package/dist/terminal/TerminalFocusManager.js +5 -7
- package/dist/terminal/TerminalFocusManager.js.map +1 -1
- package/dist/terminal/TtyWriter.d.ts.map +1 -1
- package/dist/terminal/TtyWriter.js +3 -12
- package/dist/terminal/TtyWriter.js.map +1 -1
- package/dist/utils/ClaudeSessionParser.d.ts +2 -0
- package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
- package/dist/utils/ClaudeSessionParser.js +48 -5
- package/dist/utils/ClaudeSessionParser.js.map +1 -1
- package/dist/utils/applescript.d.ts +6 -0
- package/dist/utils/applescript.d.ts.map +1 -0
- package/dist/utils/applescript.js +14 -0
- package/dist/utils/applescript.js.map +1 -0
- package/dist/utils/session.d.ts +34 -5
- package/dist/utils/session.d.ts.map +1 -1
- package/dist/utils/session.js +90 -44
- package/dist/utils/session.js.map +1 -1
- package/package.json +1 -1
- package/src/AgentManager.ts +55 -3
- package/src/__tests__/AgentManager.test.ts +134 -2
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +229 -3
- package/src/__tests__/adapters/CodexAdapter.test.ts +123 -3
- package/src/__tests__/adapters/GeminiCliAdapter.test.ts +133 -0
- package/src/__tests__/utils/ClaudeSessionParser.test.ts +195 -0
- package/src/__tests__/utils/session.test.ts +79 -43
- package/src/adapters/AgentAdapter.ts +76 -0
- package/src/adapters/ClaudeCodeAdapter.ts +136 -6
- package/src/adapters/CodexAdapter.ts +126 -8
- package/src/adapters/GeminiCliAdapter.ts +102 -7
- package/src/index.ts +9 -1
- package/src/terminal/TerminalFocusManager.ts +1 -4
- package/src/terminal/TtyWriter.ts +1 -11
- package/src/utils/ClaudeSessionParser.ts +59 -5
- package/src/utils/applescript.ts +10 -0
- 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
|
|
6
|
+
import * as fs from 'fs';
|
|
7
7
|
import { batchGetSessionFileBirthtimes } from '../../utils/session';
|
|
8
8
|
|
|
9
|
-
jest.mock('
|
|
10
|
-
|
|
9
|
+
jest.mock('fs', () => ({
|
|
10
|
+
readdirSync: jest.fn(),
|
|
11
|
+
statSync: jest.fn(),
|
|
11
12
|
}));
|
|
12
13
|
|
|
13
|
-
const
|
|
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
|
-
|
|
19
|
+
mockedReaddirSync.mockReset();
|
|
20
|
+
mockedStatSync.mockReset();
|
|
18
21
|
});
|
|
19
22
|
|
|
20
|
-
it('should parse
|
|
21
|
-
|
|
22
|
-
'
|
|
23
|
-
'
|
|
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(
|
|
48
|
+
expect(mockedReaddirSync).not.toHaveBeenCalled();
|
|
43
49
|
});
|
|
44
50
|
|
|
45
|
-
it('should return empty array on
|
|
46
|
-
|
|
47
|
-
throw new Error('
|
|
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
|
|
54
|
-
|
|
55
|
-
'
|
|
56
|
-
'
|
|
57
|
-
'
|
|
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
|
|
66
|
-
|
|
67
|
-
'
|
|
68
|
-
'
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
83
|
-
'
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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(
|
|
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-
|
|
115
|
-
expect(results[2].projectDir).toBe('/projects/app-
|
|
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 {
|
|
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:
|
|
78
|
-
|
|
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:
|
|
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
|
}
|