@ai-devkit/agent-manager 0.10.0 → 0.11.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 +14 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +60 -0
- 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 +159 -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 +76 -2
- 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
package/dist/utils/session.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Session File Utilities
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* Uses
|
|
5
|
+
* Utilities for discovering session files and their birth times.
|
|
6
|
+
* Uses Node.js fs APIs to get birth timestamps without reading file contents.
|
|
7
7
|
*/
|
|
8
8
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
9
|
if (k2 === undefined) k2 = k;
|
|
@@ -39,62 +39,108 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
39
39
|
};
|
|
40
40
|
})();
|
|
41
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.isDirectory = isDirectory;
|
|
43
|
+
exports.safeStat = safeStat;
|
|
44
|
+
exports.safeReadFile = safeReadFile;
|
|
45
|
+
exports.safeReaddir = safeReaddir;
|
|
46
|
+
exports.listJsonl = listJsonl;
|
|
42
47
|
exports.batchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes;
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
43
49
|
const path = __importStar(require("path"));
|
|
44
|
-
const child_process_1 = require("child_process");
|
|
45
50
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* Combines all directory globs into one `stat` command to avoid per-directory exec overhead.
|
|
49
|
-
* Returns empty array if no directories have .jsonl files or command fails.
|
|
50
|
-
* resolvedCwd is left empty — the adapter must set it.
|
|
51
|
+
* Check whether a path exists and is a directory.
|
|
52
|
+
* Returns false on any error (missing path, permission denied, broken symlink, etc.).
|
|
51
53
|
*/
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
function isDirectory(p) {
|
|
55
|
+
return safeStat(p)?.isDirectory() ?? false;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* `fs.statSync` that swallows errors and returns `undefined` on failure.
|
|
59
|
+
* Callers can pull whichever fields they need (mtime, birthtime, ...).
|
|
60
|
+
*/
|
|
61
|
+
function safeStat(filePath) {
|
|
55
62
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
return fs.statSync(filePath);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* `fs.readFileSync` (utf-8) that swallows errors and returns `undefined`
|
|
71
|
+
* on failure. Use when an unreadable file should be skipped rather than
|
|
72
|
+
* raised.
|
|
73
|
+
*/
|
|
74
|
+
function safeReadFile(filePath) {
|
|
75
|
+
try {
|
|
76
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `fs.readdirSync` that swallows errors and returns `[]` on failure.
|
|
84
|
+
* Useful when walking optional/transient directories where missing or
|
|
85
|
+
* unreadable entries should be skipped silently.
|
|
86
|
+
*/
|
|
87
|
+
function safeReaddir(dir) {
|
|
88
|
+
try {
|
|
89
|
+
return fs.readdirSync(dir);
|
|
64
90
|
}
|
|
65
91
|
catch {
|
|
66
92
|
return [];
|
|
67
93
|
}
|
|
68
94
|
}
|
|
69
95
|
/**
|
|
70
|
-
*
|
|
96
|
+
* List entries in a directory that end with `.jsonl`. Returns `[]` on
|
|
97
|
+
* read errors. The result preserves directory order (no sorting).
|
|
98
|
+
*/
|
|
99
|
+
function listJsonl(dir) {
|
|
100
|
+
return safeReaddir(dir).filter((name) => name.endsWith('.jsonl'));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Get birth times for .jsonl session files across multiple directories.
|
|
104
|
+
*
|
|
105
|
+
* Enumerates each directory with readdirSync and stats each .jsonl file
|
|
106
|
+
* to get its birth time. No shell commands are used.
|
|
107
|
+
* Returns empty array if no directories have .jsonl files or reads fail.
|
|
108
|
+
* resolvedCwd is left empty — the adapter must set it.
|
|
71
109
|
*/
|
|
72
|
-
function
|
|
110
|
+
function batchGetSessionFileBirthtimes(dirs) {
|
|
111
|
+
if (dirs.length === 0)
|
|
112
|
+
return [];
|
|
73
113
|
const results = [];
|
|
74
|
-
for (const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (spaceIdx === -1)
|
|
81
|
-
continue;
|
|
82
|
-
const epochStr = line.slice(0, spaceIdx);
|
|
83
|
-
const filePath = line.slice(spaceIdx + 1).trim();
|
|
84
|
-
const epochSeconds = parseInt(epochStr, 10);
|
|
85
|
-
if (!Number.isFinite(epochSeconds) || epochSeconds <= 0)
|
|
86
|
-
continue;
|
|
87
|
-
const fileName = path.basename(filePath);
|
|
88
|
-
if (!fileName.endsWith('.jsonl'))
|
|
114
|
+
for (const dir of dirs) {
|
|
115
|
+
let entries;
|
|
116
|
+
try {
|
|
117
|
+
entries = fs.readdirSync(dir);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
89
120
|
continue;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
birthtimeMs
|
|
96
|
-
|
|
97
|
-
|
|
121
|
+
}
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (!entry.endsWith('.jsonl'))
|
|
124
|
+
continue;
|
|
125
|
+
const filePath = path.join(dir, entry);
|
|
126
|
+
let birthtimeMs;
|
|
127
|
+
try {
|
|
128
|
+
birthtimeMs = fs.statSync(filePath).birthtimeMs;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!Number.isFinite(birthtimeMs) || birthtimeMs <= 0)
|
|
134
|
+
continue;
|
|
135
|
+
const sessionId = entry.replace(/\.jsonl$/, '');
|
|
136
|
+
results.push({
|
|
137
|
+
sessionId,
|
|
138
|
+
filePath,
|
|
139
|
+
projectDir: dir,
|
|
140
|
+
birthtimeMs,
|
|
141
|
+
resolvedCwd: '',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
98
144
|
}
|
|
99
145
|
return results;
|
|
100
146
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/utils/session.ts"],"names":[],"mappings":";AAAA;;;;;GAKG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/utils/session.ts"],"names":[],"mappings":";AAAA;;;;;GAKG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BH,kCAEC;AAMD,4BAMC;AAOD,oCAMC;AAOD,kCAMC;AAMD,8BAEC;AAUD,sEAwCC;AA7HD,uCAAyB;AACzB,2CAA6B;AAsB7B;;;GAGG;AACH,SAAgB,WAAW,CAAC,CAAS;IACjC,OAAO,QAAQ,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,KAAK,CAAC;AAC/C,CAAC;AAED;;;GAGG;AACH,SAAgB,QAAQ,CAAC,QAAgB;IACrC,IAAI,CAAC;QACD,OAAO,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACjC,CAAC;IAAC,MAAM,CAAC;QACL,OAAO,SAAS,CAAC;IACrB,CAAC;AACL,CAAC;AAED;;;;GAIG;AACH,SAAgB,YAAY,CAAC,QAAgB;IACzC,IAAI,CAAC;QACD,OAAO,EAAE,CAAC,YAAY,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IAC9C,CAAC;IAAC,MAAM,CAAC;QACL,OAAO,SAAS,CAAC;IACrB,CAAC;AACL,CAAC;AAED;;;;GAIG;AACH,SAAgB,WAAW,CAAC,GAAW;IACnC,IAAI,CAAC;QACD,OAAO,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACL,OAAO,EAAE,CAAC;IACd,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,SAAgB,SAAS,CAAC,GAAW;IACjC,OAAO,WAAW,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;AACtE,CAAC;AAED;;;;;;;GAOG;AACH,SAAgB,6BAA6B,CAAC,IAAc;IACxD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAEjC,MAAM,OAAO,GAAkB,EAAE,CAAC;IAElC,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,OAAiB,CAAC;QACtB,IAAI,CAAC;YACD,OAAO,GAAG,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;QAClC,CAAC;QAAC,MAAM,CAAC;YACL,SAAS;QACb,CAAC;QAED,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;YAC1B,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC;gBAAE,SAAS;YAExC,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;YAEvC,IAAI,WAAmB,CAAC;YACxB,IAAI,CAAC;gBACD,WAAW,GAAG,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,WAAW,CAAC;YACpD,CAAC;YAAC,MAAM,CAAC;gBACL,SAAS;YACb,CAAC;YAED,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,WAAW,IAAI,CAAC;gBAAE,SAAS;YAEhE,MAAM,SAAS,GAAG,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;YAEhD,OAAO,CAAC,IAAI,CAAC;gBACT,SAAS;gBACT,QAAQ;gBACR,UAAU,EAAE,GAAG;gBACf,WAAW;gBACX,WAAW,EAAE,EAAE;aAClB,CAAC,CAAC;QACP,CAAC;IACL,CAAC;IAED,OAAO,OAAO,CAAC;AACnB,CAAC"}
|
package/package.json
CHANGED
package/src/AgentManager.ts
CHANGED
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
* Manages adapter registration and aggregates results from all adapters.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
AgentAdapter,
|
|
10
|
+
AgentInfo,
|
|
11
|
+
SessionSummary,
|
|
12
|
+
ListSessionsOptions,
|
|
13
|
+
} from './adapters/AgentAdapter';
|
|
9
14
|
import { AgentStatus } from './adapters/AgentAdapter';
|
|
10
15
|
|
|
11
16
|
/**
|
|
@@ -141,12 +146,59 @@ export class AgentManager {
|
|
|
141
146
|
return this.sortAgentsByStatus(allAgents);
|
|
142
147
|
}
|
|
143
148
|
|
|
149
|
+
/**
|
|
150
|
+
* List historical sessions across every registered adapter.
|
|
151
|
+
*
|
|
152
|
+
* When `opts.type` is set, adapters whose `type` doesn't match are
|
|
153
|
+
* skipped without being called. The remaining adapters' results are
|
|
154
|
+
* merged and sorted by `lastActive` descending. Adapter failures are
|
|
155
|
+
* caught (one-line stderr warning) so one broken adapter doesn't hide
|
|
156
|
+
* the others.
|
|
157
|
+
*
|
|
158
|
+
* @param opts Filter options computed by the CLI; the manager passes
|
|
159
|
+
* them through to each adapter unchanged.
|
|
160
|
+
*/
|
|
161
|
+
async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
|
|
162
|
+
const targetAdapters = Array.from(this.adapters.values()).filter(
|
|
163
|
+
(adapter) => opts?.type === undefined || adapter.type === opts.type,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const errors: Array<{ type: string; error: Error }> = [];
|
|
167
|
+
|
|
168
|
+
const results = await Promise.all(
|
|
169
|
+
targetAdapters.map(async (adapter) => {
|
|
170
|
+
try {
|
|
171
|
+
return await adapter.listSessions(opts);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
174
|
+
errors.push({ type: adapter.type, error: err });
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
if (errors.length > 0) {
|
|
181
|
+
console.error(`Warning: ${errors.length} adapter(s) failed to list sessions:`);
|
|
182
|
+
for (const { type, error } of errors) {
|
|
183
|
+
console.error(` - ${type}: ${error.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const merged: SessionSummary[] = [];
|
|
188
|
+
for (const list of results) {
|
|
189
|
+
merged.push(...list);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
merged.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime());
|
|
193
|
+
return merged;
|
|
194
|
+
}
|
|
195
|
+
|
|
144
196
|
/**
|
|
145
197
|
* Sort agents by status priority
|
|
146
|
-
*
|
|
198
|
+
*
|
|
147
199
|
* Priority order: waiting > running > idle > unknown
|
|
148
200
|
* This ensures agents that need attention appear first.
|
|
149
|
-
*
|
|
201
|
+
*
|
|
150
202
|
* @param agents Array of agents to sort
|
|
151
203
|
* @returns Sorted array of agents
|
|
152
204
|
*/
|
|
@@ -4,15 +4,25 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, it, expect, beforeEach } from '@jest/globals';
|
|
6
6
|
import { AgentManager } from '../AgentManager';
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
AgentAdapter,
|
|
9
|
+
AgentInfo,
|
|
10
|
+
AgentType,
|
|
11
|
+
ConversationMessage,
|
|
12
|
+
SessionSummary,
|
|
13
|
+
} from '../adapters/AgentAdapter';
|
|
8
14
|
import { AgentStatus } from '../adapters/AgentAdapter';
|
|
9
15
|
|
|
10
16
|
// Mock adapter for testing
|
|
11
17
|
class MockAdapter implements AgentAdapter {
|
|
18
|
+
public lastListSessionsOpts: unknown = undefined;
|
|
19
|
+
|
|
12
20
|
constructor(
|
|
13
21
|
public readonly type: AgentType,
|
|
14
22
|
private mockAgents: AgentInfo[] = [],
|
|
15
|
-
private shouldFail: boolean = false
|
|
23
|
+
private shouldFail: boolean = false,
|
|
24
|
+
private mockSessions: SessionSummary[] = [],
|
|
25
|
+
private shouldFailListSessions: boolean = false,
|
|
16
26
|
) { }
|
|
17
27
|
|
|
18
28
|
async detectAgents(): Promise<AgentInfo[]> {
|
|
@@ -30,6 +40,14 @@ class MockAdapter implements AgentAdapter {
|
|
|
30
40
|
return [];
|
|
31
41
|
}
|
|
32
42
|
|
|
43
|
+
async listSessions(opts?: unknown): Promise<SessionSummary[]> {
|
|
44
|
+
this.lastListSessionsOpts = opts;
|
|
45
|
+
if (this.shouldFailListSessions) {
|
|
46
|
+
throw new Error(`Mock adapter ${this.type} listSessions failed`);
|
|
47
|
+
}
|
|
48
|
+
return this.mockSessions;
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
setAgents(agents: AgentInfo[]): void {
|
|
34
52
|
this.mockAgents = agents;
|
|
35
53
|
}
|
|
@@ -37,6 +55,14 @@ class MockAdapter implements AgentAdapter {
|
|
|
37
55
|
setFail(shouldFail: boolean): void {
|
|
38
56
|
this.shouldFail = shouldFail;
|
|
39
57
|
}
|
|
58
|
+
|
|
59
|
+
setSessions(sessions: SessionSummary[]): void {
|
|
60
|
+
this.mockSessions = sessions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setFailListSessions(shouldFail: boolean): void {
|
|
64
|
+
this.shouldFailListSessions = shouldFail;
|
|
65
|
+
}
|
|
40
66
|
}
|
|
41
67
|
|
|
42
68
|
// Helper to create mock agent
|
|
@@ -226,6 +252,112 @@ describe('AgentManager', () => {
|
|
|
226
252
|
});
|
|
227
253
|
});
|
|
228
254
|
|
|
255
|
+
describe('listSessions', () => {
|
|
256
|
+
function createMockSession(overrides: Partial<SessionSummary> = {}): SessionSummary {
|
|
257
|
+
return {
|
|
258
|
+
type: 'claude',
|
|
259
|
+
sessionId: 'session-1',
|
|
260
|
+
cwd: '/repo',
|
|
261
|
+
firstUserMessage: 'hello',
|
|
262
|
+
lastActive: new Date('2025-01-01T00:00:00Z'),
|
|
263
|
+
startedAt: new Date('2025-01-01T00:00:00Z'),
|
|
264
|
+
sessionFilePath: '/tmp/session-1.jsonl',
|
|
265
|
+
...overrides,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
it('returns empty array when no adapters are registered', async () => {
|
|
270
|
+
const result = await manager.listSessions();
|
|
271
|
+
expect(result).toEqual([]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('merges sessions from every registered adapter', async () => {
|
|
275
|
+
const claudeSession = createMockSession({ type: 'claude', sessionId: 'c1' });
|
|
276
|
+
const codexSession = createMockSession({ type: 'codex', sessionId: 'cx1' });
|
|
277
|
+
manager.registerAdapter(new MockAdapter('claude', [], false, [claudeSession]));
|
|
278
|
+
manager.registerAdapter(new MockAdapter('codex', [], false, [codexSession]));
|
|
279
|
+
|
|
280
|
+
const result = await manager.listSessions();
|
|
281
|
+
|
|
282
|
+
expect(result).toHaveLength(2);
|
|
283
|
+
expect(result.map((s) => s.sessionId).sort()).toEqual(['c1', 'cx1']);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('sorts merged sessions by lastActive descending', async () => {
|
|
287
|
+
const older = createMockSession({
|
|
288
|
+
sessionId: 'older',
|
|
289
|
+
lastActive: new Date('2025-01-01T00:00:00Z'),
|
|
290
|
+
});
|
|
291
|
+
const newer = createMockSession({
|
|
292
|
+
type: 'codex',
|
|
293
|
+
sessionId: 'newer',
|
|
294
|
+
lastActive: new Date('2025-06-01T00:00:00Z'),
|
|
295
|
+
});
|
|
296
|
+
manager.registerAdapter(new MockAdapter('claude', [], false, [older]));
|
|
297
|
+
manager.registerAdapter(new MockAdapter('codex', [], false, [newer]));
|
|
298
|
+
|
|
299
|
+
const result = await manager.listSessions();
|
|
300
|
+
|
|
301
|
+
expect(result.map((s) => s.sessionId)).toEqual(['newer', 'older']);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('skips adapters whose type does not match opts.type', async () => {
|
|
305
|
+
const claudeAdapter = new MockAdapter(
|
|
306
|
+
'claude',
|
|
307
|
+
[],
|
|
308
|
+
false,
|
|
309
|
+
[createMockSession({ type: 'claude', sessionId: 'c1' })],
|
|
310
|
+
);
|
|
311
|
+
const codexAdapter = new MockAdapter(
|
|
312
|
+
'codex',
|
|
313
|
+
[],
|
|
314
|
+
false,
|
|
315
|
+
[createMockSession({ type: 'codex', sessionId: 'cx1' })],
|
|
316
|
+
);
|
|
317
|
+
manager.registerAdapter(claudeAdapter);
|
|
318
|
+
manager.registerAdapter(codexAdapter);
|
|
319
|
+
|
|
320
|
+
const result = await manager.listSessions({ type: 'claude' });
|
|
321
|
+
|
|
322
|
+
expect(result).toHaveLength(1);
|
|
323
|
+
expect(result[0].sessionId).toBe('c1');
|
|
324
|
+
// Codex adapter must not have been called
|
|
325
|
+
expect(codexAdapter.lastListSessionsOpts).toBeUndefined();
|
|
326
|
+
expect(claudeAdapter.lastListSessionsOpts).toEqual({ type: 'claude' });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('tolerates an adapter that throws and still returns the others', async () => {
|
|
330
|
+
const goodSession = createMockSession({ sessionId: 'good' });
|
|
331
|
+
manager.registerAdapter(new MockAdapter('claude', [], false, [goodSession]));
|
|
332
|
+
manager.registerAdapter(
|
|
333
|
+
new MockAdapter('codex', [], false, [], true /* failListSessions */),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const result = await manager.listSessions();
|
|
340
|
+
expect(result).toHaveLength(1);
|
|
341
|
+
expect(result[0].sessionId).toBe('good');
|
|
342
|
+
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
343
|
+
} finally {
|
|
344
|
+
consoleErrorSpy.mockRestore();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('passes the same opts to every called adapter', async () => {
|
|
349
|
+
const a = new MockAdapter('claude', [], false, []);
|
|
350
|
+
const b = new MockAdapter('codex', [], false, []);
|
|
351
|
+
manager.registerAdapter(a);
|
|
352
|
+
manager.registerAdapter(b);
|
|
353
|
+
|
|
354
|
+
await manager.listSessions({ cwd: '/Users/test/proj' });
|
|
355
|
+
|
|
356
|
+
expect(a.lastListSessionsOpts).toEqual({ cwd: '/Users/test/proj' });
|
|
357
|
+
expect(b.lastListSessionsOpts).toEqual({ cwd: '/Users/test/proj' });
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
229
361
|
describe('resolveAgent', () => {
|
|
230
362
|
it('should return null for empty input or empty agents list', () => {
|
|
231
363
|
const agent = createMockAgent({ name: 'test-agent' });
|
|
@@ -18,9 +18,13 @@ jest.mock('../../utils/process', () => ({
|
|
|
18
18
|
enrichProcesses: jest.fn(),
|
|
19
19
|
}));
|
|
20
20
|
|
|
21
|
-
jest.mock('../../utils/session', () =>
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
jest.mock('../../utils/session', () => {
|
|
22
|
+
const actual = jest.requireActual('../../utils/session') as typeof import('../../utils/session');
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
batchGetSessionFileBirthtimes: jest.fn(),
|
|
26
|
+
};
|
|
27
|
+
});
|
|
24
28
|
|
|
25
29
|
jest.mock('../../utils/matching', () => ({
|
|
26
30
|
matchProcessesToSessions: jest.fn(),
|
|
@@ -1288,4 +1292,156 @@ describe('ClaudeCodeAdapter', () => {
|
|
|
1288
1292
|
expect(messages[0].content).toBe('Real question');
|
|
1289
1293
|
});
|
|
1290
1294
|
});
|
|
1295
|
+
|
|
1296
|
+
describe('listSessions', () => {
|
|
1297
|
+
let tmpDir: string;
|
|
1298
|
+
let projectsDir: string;
|
|
1299
|
+
|
|
1300
|
+
beforeEach(() => {
|
|
1301
|
+
tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-list-'));
|
|
1302
|
+
projectsDir = path.join(tmpDir, 'projects');
|
|
1303
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
1304
|
+
(adapter as any).projectsDir = projectsDir;
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
afterEach(() => {
|
|
1308
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
function writeSession(projectDir: string, sessionId: string, lines: object[]): string {
|
|
1312
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
1313
|
+
const filePath = path.join(projectDir, `${sessionId}.jsonl`);
|
|
1314
|
+
fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n'));
|
|
1315
|
+
return filePath;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
it('returns empty when projects dir does not exist', async () => {
|
|
1319
|
+
fs.rmSync(projectsDir, { recursive: true, force: true });
|
|
1320
|
+
const result = await adapter.listSessions();
|
|
1321
|
+
expect(result).toEqual([]);
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
it('returns sessions from a single cwd-scoped project dir', async () => {
|
|
1325
|
+
const cwd = '/Users/test/proj';
|
|
1326
|
+
const projDir = path.join(projectsDir, '-Users-test-proj');
|
|
1327
|
+
const filePath = writeSession(projDir, 'sess-1', [
|
|
1328
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'first prompt' } },
|
|
1329
|
+
{ type: 'assistant', timestamp: '2025-01-01T00:01:00Z' },
|
|
1330
|
+
]);
|
|
1331
|
+
|
|
1332
|
+
const result = await adapter.listSessions({ cwd });
|
|
1333
|
+
|
|
1334
|
+
expect(result).toHaveLength(1);
|
|
1335
|
+
expect(result[0]).toMatchObject({
|
|
1336
|
+
type: 'claude',
|
|
1337
|
+
sessionId: 'sess-1',
|
|
1338
|
+
cwd,
|
|
1339
|
+
firstUserMessage: 'first prompt',
|
|
1340
|
+
sessionFilePath: filePath,
|
|
1341
|
+
});
|
|
1342
|
+
expect(result[0].lastActive).toBeInstanceOf(Date);
|
|
1343
|
+
expect(result[0].startedAt).toBeInstanceOf(Date);
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
it('lists sessions from all project dirs when no cwd filter', async () => {
|
|
1347
|
+
const cwdA = '/Users/test/proj-a';
|
|
1348
|
+
const cwdB = '/Users/test/proj-b';
|
|
1349
|
+
writeSession(path.join(projectsDir, '-Users-test-proj-a'), 'a', [
|
|
1350
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: cwdA, message: { content: 'msg-a' } },
|
|
1351
|
+
]);
|
|
1352
|
+
writeSession(path.join(projectsDir, '-Users-test-proj-b'), 'b', [
|
|
1353
|
+
{ type: 'user', timestamp: '2025-01-02T00:00:00Z', cwd: cwdB, message: { content: 'msg-b' } },
|
|
1354
|
+
]);
|
|
1355
|
+
|
|
1356
|
+
const result = await adapter.listSessions();
|
|
1357
|
+
|
|
1358
|
+
expect(result).toHaveLength(2);
|
|
1359
|
+
expect(result.map((r) => r.sessionId).sort()).toEqual(['a', 'b']);
|
|
1360
|
+
const cwds = result.map((r) => r.cwd).sort();
|
|
1361
|
+
expect(cwds).toEqual([cwdA, cwdB]);
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it('drops sessions whose recorded cwd does not match opts.cwd (strict equality)', async () => {
|
|
1365
|
+
const cwdReal = '/Users/test/foo';
|
|
1366
|
+
const cwdRequested = '/Users/test/foo/sub';
|
|
1367
|
+
writeSession(path.join(projectsDir, '-Users-test-foo'), 's', [
|
|
1368
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: cwdReal, message: { content: 'hi' } },
|
|
1369
|
+
]);
|
|
1370
|
+
|
|
1371
|
+
// Encoded dir for the requested cwd doesn't exist → return []
|
|
1372
|
+
const result = await adapter.listSessions({ cwd: cwdRequested });
|
|
1373
|
+
expect(result).toEqual([]);
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
it('drops sessions whose recorded cwd disagrees with the encoded dir', async () => {
|
|
1377
|
+
// Edge case: encoded dir lookup matches, but session content
|
|
1378
|
+
// records a different cwd. Strict-equality filter must reject.
|
|
1379
|
+
const requested = '/Users/test/proj';
|
|
1380
|
+
const projDir = path.join(projectsDir, '-Users-test-proj');
|
|
1381
|
+
writeSession(projDir, 's', [
|
|
1382
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: '/different/path', message: { content: 'mismatch' } },
|
|
1383
|
+
]);
|
|
1384
|
+
|
|
1385
|
+
const result = await adapter.listSessions({ cwd: requested });
|
|
1386
|
+
expect(result).toEqual([]);
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
it('finds sessions whose recorded cwd lives in a different encoded dir (worktree case)', async () => {
|
|
1390
|
+
// Real-world case: Claude Code is launched in /repo, then chdirs into
|
|
1391
|
+
// /repo/.worktrees/feature. The session file is stored under the
|
|
1392
|
+
// ENCODED launch dir, but its content records the worktree path.
|
|
1393
|
+
// listSessions({ cwd: worktree }) must still find it.
|
|
1394
|
+
const launchDir = path.join(projectsDir, '-repo');
|
|
1395
|
+
const worktreeCwd = '/repo/.worktrees/feature';
|
|
1396
|
+
writeSession(launchDir, 'wt', [
|
|
1397
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: worktreeCwd, message: { content: 'in worktree' } },
|
|
1398
|
+
]);
|
|
1399
|
+
|
|
1400
|
+
const result = await adapter.listSessions({ cwd: worktreeCwd });
|
|
1401
|
+
expect(result).toHaveLength(1);
|
|
1402
|
+
expect(result[0]).toMatchObject({
|
|
1403
|
+
sessionId: 'wt',
|
|
1404
|
+
cwd: worktreeCwd,
|
|
1405
|
+
firstUserMessage: 'in worktree',
|
|
1406
|
+
});
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
it('skips malformed session files', async () => {
|
|
1410
|
+
const cwd = '/Users/test/p';
|
|
1411
|
+
const projDir = path.join(projectsDir, '-Users-test-p');
|
|
1412
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
1413
|
+
fs.writeFileSync(path.join(projDir, 'bad.jsonl'), 'not valid json');
|
|
1414
|
+
writeSession(projDir, 'good', [
|
|
1415
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'ok' } },
|
|
1416
|
+
]);
|
|
1417
|
+
|
|
1418
|
+
const result = await adapter.listSessions({ cwd });
|
|
1419
|
+
expect(result).toHaveLength(1);
|
|
1420
|
+
expect(result[0].sessionId).toBe('good');
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
it('captures first user message after filtering noise', async () => {
|
|
1424
|
+
const cwd = '/Users/test/q';
|
|
1425
|
+
writeSession(path.join(projectsDir, '-Users-test-q'), 's', [
|
|
1426
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'Tool loaded.' } },
|
|
1427
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:01Z', cwd, message: { content: 'real first prompt' } },
|
|
1428
|
+
{ type: 'user', timestamp: '2025-01-01T00:00:02Z', cwd, message: { content: 'second prompt' } },
|
|
1429
|
+
]);
|
|
1430
|
+
|
|
1431
|
+
const result = await adapter.listSessions({ cwd });
|
|
1432
|
+
expect(result).toHaveLength(1);
|
|
1433
|
+
expect(result[0].firstUserMessage).toBe('real first prompt');
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
it('returns empty firstUserMessage when no user message exists', async () => {
|
|
1437
|
+
const cwd = '/Users/test/empty';
|
|
1438
|
+
writeSession(path.join(projectsDir, '-Users-test-empty'), 's', [
|
|
1439
|
+
{ type: 'assistant', timestamp: '2025-01-01T00:00:00Z' },
|
|
1440
|
+
]);
|
|
1441
|
+
|
|
1442
|
+
const result = await adapter.listSessions({ cwd });
|
|
1443
|
+
expect(result).toHaveLength(1);
|
|
1444
|
+
expect(result[0].firstUserMessage).toBe('');
|
|
1445
|
+
});
|
|
1446
|
+
});
|
|
1291
1447
|
});
|