@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
|
@@ -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';
|
|
@@ -265,4 +272,71 @@ export class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
265
272
|
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
|
|
266
273
|
return this.parser.getConversation(sessionFilePath, options);
|
|
267
274
|
}
|
|
275
|
+
|
|
276
|
+
async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
|
|
277
|
+
const filterCwd = opts?.cwd;
|
|
278
|
+
const candidates = this.discoverSessionFiles();
|
|
279
|
+
const summaries: SessionSummary[] = [];
|
|
280
|
+
|
|
281
|
+
for (const { filePath, defaultCwd } of candidates) {
|
|
282
|
+
const session = this.parser.readSession(filePath, defaultCwd);
|
|
283
|
+
if (!session) continue;
|
|
284
|
+
|
|
285
|
+
// Drop sessions whose JSONL had no parseable conversation entries.
|
|
286
|
+
// readSession is permissive (returns a shell record even when every
|
|
287
|
+
// line fails to parse); listSessions needs at least one real entry
|
|
288
|
+
// so we don't surface garbage files.
|
|
289
|
+
if (!session.lastEntryType) continue;
|
|
290
|
+
|
|
291
|
+
const recordedCwd = session.lastCwd || defaultCwd;
|
|
292
|
+
if (filterCwd !== undefined && recordedCwd !== filterCwd) continue;
|
|
293
|
+
|
|
294
|
+
const stat = safeStat(filePath);
|
|
295
|
+
|
|
296
|
+
summaries.push({
|
|
297
|
+
type: 'claude',
|
|
298
|
+
sessionId: session.sessionId,
|
|
299
|
+
cwd: recordedCwd,
|
|
300
|
+
firstUserMessage: session.firstUserMessage || '',
|
|
301
|
+
lastActive: session.lastActive ?? stat?.mtime ?? new Date(),
|
|
302
|
+
startedAt: session.sessionStart ?? stat?.birthtime ?? stat?.mtime ?? new Date(),
|
|
303
|
+
sessionFilePath: filePath,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return summaries;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Discover candidate session files for {@link listSessions}.
|
|
312
|
+
*
|
|
313
|
+
* Always walks every subdirectory of `projectsDir`. We can't use the
|
|
314
|
+
* encoded-dir shortcut for the cwd-scoped path because Claude Code
|
|
315
|
+
* indexes session files by where the *process was launched*, not by
|
|
316
|
+
* the recorded `cwd` field inside the session — these diverge in
|
|
317
|
+
* worktrees and similar setups. The cwd filter is applied later
|
|
318
|
+
* against `session.lastCwd` so callers see exactly the sessions whose
|
|
319
|
+
* recorded cwd matches.
|
|
320
|
+
*/
|
|
321
|
+
private discoverSessionFiles(): Array<{ filePath: string; defaultCwd: string }> {
|
|
322
|
+
const out: Array<{ filePath: string; defaultCwd: string }> = [];
|
|
323
|
+
|
|
324
|
+
if (!isDirectory(this.projectsDir)) return out;
|
|
325
|
+
|
|
326
|
+
for (const dirName of safeReaddir(this.projectsDir)) {
|
|
327
|
+
const projectDir = path.join(this.projectsDir, dirName);
|
|
328
|
+
if (!isDirectory(projectDir)) continue;
|
|
329
|
+
|
|
330
|
+
// Best-effort decode for the rare case session content has no
|
|
331
|
+
// recorded cwd: '-Users-foo-bar' → '/Users/foo/bar'. Lossy for
|
|
332
|
+
// paths containing '-'; session content's lastCwd overrides
|
|
333
|
+
// this when available.
|
|
334
|
+
const decoded = dirName.replace(/-/g, '/');
|
|
335
|
+
for (const name of listJsonl(projectDir)) {
|
|
336
|
+
out.push({ filePath: path.join(projectDir, name), defaultCwd: decoded });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
268
342
|
}
|
|
@@ -12,10 +12,17 @@
|
|
|
12
12
|
|
|
13
13
|
import * as fs from 'fs';
|
|
14
14
|
import * as path from 'path';
|
|
15
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
AgentAdapter,
|
|
17
|
+
AgentInfo,
|
|
18
|
+
ProcessInfo,
|
|
19
|
+
ConversationMessage,
|
|
20
|
+
SessionSummary,
|
|
21
|
+
ListSessionsOptions,
|
|
22
|
+
} from './AgentAdapter';
|
|
16
23
|
import { AgentStatus } from './AgentAdapter';
|
|
17
24
|
import { listAgentProcesses, enrichProcesses } from '../utils/process';
|
|
18
|
-
import { batchGetSessionFileBirthtimes } from '../utils/session';
|
|
25
|
+
import { batchGetSessionFileBirthtimes, isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session';
|
|
19
26
|
import type { SessionFile } from '../utils/session';
|
|
20
27
|
import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
|
|
21
28
|
|
|
@@ -326,12 +333,8 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
326
333
|
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
|
|
327
334
|
const verbose = options?.verbose ?? false;
|
|
328
335
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
content = fs.readFileSync(sessionFilePath, 'utf-8');
|
|
332
|
-
} catch {
|
|
333
|
-
return [];
|
|
334
|
-
}
|
|
336
|
+
const content = safeReadFile(sessionFilePath);
|
|
337
|
+
if (content === undefined) return [];
|
|
335
338
|
|
|
336
339
|
const lines = content.trim().split('\n');
|
|
337
340
|
const messages: ConversationMessage[] = [];
|
|
@@ -372,4 +375,119 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
372
375
|
|
|
373
376
|
return messages;
|
|
374
377
|
}
|
|
378
|
+
|
|
379
|
+
async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
|
|
380
|
+
if (!isDirectory(this.codexSessionsDir)) return [];
|
|
381
|
+
|
|
382
|
+
const files = this.collectAllSessionFiles();
|
|
383
|
+
const summaries: SessionSummary[] = [];
|
|
384
|
+
|
|
385
|
+
for (const filePath of files) {
|
|
386
|
+
const summary = this.fileToSessionSummary(filePath);
|
|
387
|
+
if (!summary) continue;
|
|
388
|
+
if (opts?.cwd !== undefined && summary.cwd !== opts.cwd) continue;
|
|
389
|
+
summaries.push(summary);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return summaries;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Walk every YYYY/MM/DD directory under `codexSessionsDir` and return
|
|
397
|
+
* absolute paths of `.jsonl` files. Tolerates malformed layouts
|
|
398
|
+
* (skips entries that aren't directories at the expected depth).
|
|
399
|
+
*/
|
|
400
|
+
private collectAllSessionFiles(): string[] {
|
|
401
|
+
const out: string[] = [];
|
|
402
|
+
|
|
403
|
+
for (const yearEntry of safeReaddir(this.codexSessionsDir)) {
|
|
404
|
+
const yearDir = path.join(this.codexSessionsDir, yearEntry);
|
|
405
|
+
if (!isDirectory(yearDir)) continue;
|
|
406
|
+
|
|
407
|
+
for (const monthEntry of safeReaddir(yearDir)) {
|
|
408
|
+
const monthDir = path.join(yearDir, monthEntry);
|
|
409
|
+
if (!isDirectory(monthDir)) continue;
|
|
410
|
+
|
|
411
|
+
for (const dayEntry of safeReaddir(monthDir)) {
|
|
412
|
+
const dayDir = path.join(monthDir, dayEntry);
|
|
413
|
+
if (!isDirectory(dayDir)) continue;
|
|
414
|
+
|
|
415
|
+
for (const fileEntry of safeReaddir(dayDir)) {
|
|
416
|
+
if (!fileEntry.endsWith('.jsonl')) continue;
|
|
417
|
+
out.push(path.join(dayDir, fileEntry));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Read a Codex session JSONL file and produce a {@link SessionSummary}.
|
|
428
|
+
* Returns null when the file is unreadable, has no `session_meta`, or
|
|
429
|
+
* lacks a session id.
|
|
430
|
+
*/
|
|
431
|
+
private fileToSessionSummary(filePath: string): SessionSummary | null {
|
|
432
|
+
const content = safeReadFile(filePath);
|
|
433
|
+
if (content === undefined) return null;
|
|
434
|
+
|
|
435
|
+
const allLines = content.trim().split('\n');
|
|
436
|
+
if (!allLines[0]) return null;
|
|
437
|
+
|
|
438
|
+
let metaEntry: CodexEventEntry;
|
|
439
|
+
try {
|
|
440
|
+
metaEntry = JSON.parse(allLines[0]);
|
|
441
|
+
} catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (metaEntry.type !== 'session_meta' || !metaEntry.payload?.id) {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
let firstUserMessage = '';
|
|
450
|
+
let lastTimestamp: Date | null = null;
|
|
451
|
+
|
|
452
|
+
for (let i = 1; i < allLines.length; i++) {
|
|
453
|
+
let entry: CodexEventEntry;
|
|
454
|
+
try {
|
|
455
|
+
entry = JSON.parse(allLines[i]);
|
|
456
|
+
} catch {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const ts = this.parseTimestamp(entry.timestamp);
|
|
461
|
+
if (ts) lastTimestamp = ts;
|
|
462
|
+
|
|
463
|
+
if (
|
|
464
|
+
!firstUserMessage &&
|
|
465
|
+
entry.payload?.type === 'user_message' &&
|
|
466
|
+
typeof entry.payload.message === 'string' &&
|
|
467
|
+
entry.payload.message.trim().length > 0
|
|
468
|
+
) {
|
|
469
|
+
firstUserMessage = entry.payload.message.trim();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const stat = safeStat(filePath);
|
|
474
|
+
|
|
475
|
+
const startedAt =
|
|
476
|
+
this.parseTimestamp(metaEntry.payload.timestamp) ||
|
|
477
|
+
lastTimestamp ||
|
|
478
|
+
stat?.birthtime ||
|
|
479
|
+
stat?.mtime ||
|
|
480
|
+
new Date();
|
|
481
|
+
const lastActive = lastTimestamp || startedAt;
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
type: 'codex',
|
|
485
|
+
sessionId: metaEntry.payload.id,
|
|
486
|
+
cwd: metaEntry.payload.cwd || '',
|
|
487
|
+
firstUserMessage,
|
|
488
|
+
lastActive,
|
|
489
|
+
startedAt,
|
|
490
|
+
sessionFilePath: filePath,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
375
493
|
}
|
|
@@ -13,9 +13,17 @@
|
|
|
13
13
|
import * as crypto from 'crypto';
|
|
14
14
|
import * as fs from 'fs';
|
|
15
15
|
import * as path from 'path';
|
|
16
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
AgentAdapter,
|
|
18
|
+
AgentInfo,
|
|
19
|
+
ProcessInfo,
|
|
20
|
+
ConversationMessage,
|
|
21
|
+
SessionSummary,
|
|
22
|
+
ListSessionsOptions,
|
|
23
|
+
} from './AgentAdapter';
|
|
17
24
|
import { AgentStatus } from './AgentAdapter';
|
|
18
25
|
import { listAgentProcesses, enrichProcesses } from '../utils/process';
|
|
26
|
+
import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session';
|
|
19
27
|
import type { SessionFile } from '../utils/session';
|
|
20
28
|
import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
|
|
21
29
|
|
|
@@ -441,12 +449,8 @@ export class GeminiCliAdapter implements AgentAdapter {
|
|
|
441
449
|
getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
|
|
442
450
|
const verbose = options?.verbose ?? false;
|
|
443
451
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
content = fs.readFileSync(sessionFilePath, 'utf-8');
|
|
447
|
-
} catch {
|
|
448
|
-
return [];
|
|
449
|
-
}
|
|
452
|
+
const content = safeReadFile(sessionFilePath);
|
|
453
|
+
if (content === undefined) return [];
|
|
450
454
|
|
|
451
455
|
let parsed: GeminiSessionFile;
|
|
452
456
|
try {
|
|
@@ -485,4 +489,95 @@ export class GeminiCliAdapter implements AgentAdapter {
|
|
|
485
489
|
|
|
486
490
|
return messages;
|
|
487
491
|
}
|
|
492
|
+
|
|
493
|
+
async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
|
|
494
|
+
if (!isDirectory(this.geminiTmpDir)) return [];
|
|
495
|
+
|
|
496
|
+
const summaries: SessionSummary[] = [];
|
|
497
|
+
|
|
498
|
+
for (const shortId of safeReaddir(this.geminiTmpDir)) {
|
|
499
|
+
const chatsDir = path.join(
|
|
500
|
+
this.geminiTmpDir,
|
|
501
|
+
shortId,
|
|
502
|
+
GeminiCliAdapter.CHATS_DIR_NAME,
|
|
503
|
+
);
|
|
504
|
+
if (!isDirectory(chatsDir)) continue;
|
|
505
|
+
|
|
506
|
+
for (const fileName of safeReaddir(chatsDir)) {
|
|
507
|
+
if (
|
|
508
|
+
!fileName.startsWith(GeminiCliAdapter.SESSION_FILE_PREFIX) ||
|
|
509
|
+
!fileName.endsWith('.json')
|
|
510
|
+
) {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const filePath = path.join(chatsDir, fileName);
|
|
515
|
+
const summary = this.fileToSessionSummary(filePath);
|
|
516
|
+
if (!summary) continue;
|
|
517
|
+
if (opts?.cwd !== undefined && summary.cwd !== opts.cwd) continue;
|
|
518
|
+
summaries.push(summary);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return summaries;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Read a Gemini session JSON file and produce a {@link SessionSummary}.
|
|
527
|
+
* Returns null when the file is unreadable, the JSON doesn't parse,
|
|
528
|
+
* or the body lacks a sessionId.
|
|
529
|
+
*/
|
|
530
|
+
private fileToSessionSummary(filePath: string): SessionSummary | null {
|
|
531
|
+
const content = safeReadFile(filePath);
|
|
532
|
+
if (content === undefined) return null;
|
|
533
|
+
|
|
534
|
+
let parsed: GeminiSessionFile;
|
|
535
|
+
try {
|
|
536
|
+
parsed = JSON.parse(content);
|
|
537
|
+
} catch {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (!parsed.sessionId) return null;
|
|
542
|
+
|
|
543
|
+
const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
|
|
544
|
+
const firstUserMessage = this.extractFirstUserMessage(messages);
|
|
545
|
+
|
|
546
|
+
const cwd =
|
|
547
|
+
Array.isArray(parsed.directories) && parsed.directories.length > 0
|
|
548
|
+
? parsed.directories[0]
|
|
549
|
+
: '';
|
|
550
|
+
|
|
551
|
+
const stat = safeStat(filePath);
|
|
552
|
+
|
|
553
|
+
const lastEntryTimestamp = this.parseTimestamp(
|
|
554
|
+
messages.length > 0 ? messages[messages.length - 1]?.timestamp : undefined,
|
|
555
|
+
);
|
|
556
|
+
const lastActive =
|
|
557
|
+
this.parseTimestamp(parsed.lastUpdated) ||
|
|
558
|
+
lastEntryTimestamp ||
|
|
559
|
+
stat?.mtime ||
|
|
560
|
+
new Date();
|
|
561
|
+
const startedAt =
|
|
562
|
+
this.parseTimestamp(parsed.startTime) || stat?.birthtime || stat?.mtime || lastActive;
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
type: 'gemini_cli',
|
|
566
|
+
sessionId: parsed.sessionId,
|
|
567
|
+
cwd,
|
|
568
|
+
firstUserMessage,
|
|
569
|
+
lastActive,
|
|
570
|
+
startedAt,
|
|
571
|
+
sessionFilePath: filePath,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private extractFirstUserMessage(messages: GeminiMessageEntry[]): string {
|
|
576
|
+
for (const entry of messages) {
|
|
577
|
+
if (entry?.type !== 'user') continue;
|
|
578
|
+
const text = this.messageText(entry).trim();
|
|
579
|
+
if (text) return text;
|
|
580
|
+
}
|
|
581
|
+
return '';
|
|
582
|
+
}
|
|
488
583
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,15 @@ export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter';
|
|
|
4
4
|
export { CodexAdapter } from './adapters/CodexAdapter';
|
|
5
5
|
export { GeminiCliAdapter } from './adapters/GeminiCliAdapter';
|
|
6
6
|
export { AgentStatus } from './adapters/AgentAdapter';
|
|
7
|
-
export type {
|
|
7
|
+
export type {
|
|
8
|
+
AgentAdapter,
|
|
9
|
+
AgentType,
|
|
10
|
+
AgentInfo,
|
|
11
|
+
ProcessInfo,
|
|
12
|
+
ConversationMessage,
|
|
13
|
+
SessionSummary,
|
|
14
|
+
ListSessionsOptions,
|
|
15
|
+
} from './adapters/AgentAdapter';
|
|
8
16
|
|
|
9
17
|
export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager';
|
|
10
18
|
export type { TerminalLocation } from './terminal/TerminalFocusManager';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from 'child_process';
|
|
2
2
|
import { promisify } from 'util';
|
|
3
3
|
import { getProcessTty } from '../utils/process';
|
|
4
|
+
import { escapeAppleScript } from '../utils/applescript';
|
|
4
5
|
|
|
5
6
|
const execFileAsync = promisify(execFile);
|
|
6
7
|
|
|
@@ -17,10 +18,6 @@ export interface TerminalLocation {
|
|
|
17
18
|
tty: string; // e.g., "/dev/ttys030"
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
function escapeAppleScript(text: string): string {
|
|
21
|
-
return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
22
|
-
}
|
|
23
|
-
|
|
24
21
|
export class TerminalFocusManager {
|
|
25
22
|
/**
|
|
26
23
|
* Find the terminal location (emulator info) for a given process ID
|
|
@@ -2,20 +2,10 @@ import { execFile } from 'child_process';
|
|
|
2
2
|
import { promisify } from 'util';
|
|
3
3
|
import type { TerminalLocation } from './TerminalFocusManager';
|
|
4
4
|
import { TerminalType } from './TerminalFocusManager';
|
|
5
|
+
import { escapeAppleScript } from '../utils/applescript';
|
|
5
6
|
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
-
* Escape a string for safe use inside an AppleScript double-quoted string.
|
|
10
|
-
* Backslashes, double quotes, and newlines must be escaped.
|
|
11
|
-
*/
|
|
12
|
-
function escapeAppleScript(text: string): string {
|
|
13
|
-
return text
|
|
14
|
-
.replace(/\\/g, '\\\\')
|
|
15
|
-
.replace(/"/g, '\\"')
|
|
16
|
-
.replace(/\r\n|\r|\n/g, '\\n');
|
|
17
|
-
}
|
|
18
|
-
|
|
19
9
|
export class TtyWriter {
|
|
20
10
|
/**
|
|
21
11
|
* Send a message as keyboard input to a terminal session.
|
|
@@ -47,6 +47,8 @@ export interface ClaudeSession {
|
|
|
47
47
|
lastEntryType?: string;
|
|
48
48
|
isInterrupted: boolean;
|
|
49
49
|
lastUserMessage?: string;
|
|
50
|
+
/** First meaningful user prompt in the session (post noise filter) */
|
|
51
|
+
firstUserMessage?: string;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
/** Entry types that are metadata, not conversation state. */
|
|
@@ -91,6 +93,7 @@ export class ClaudeSessionParser {
|
|
|
91
93
|
let lastCwd: string | undefined;
|
|
92
94
|
let isInterrupted = false;
|
|
93
95
|
let lastUserMessage: string | undefined;
|
|
96
|
+
let firstUserMessage: string | undefined;
|
|
94
97
|
|
|
95
98
|
for (const line of allLines) {
|
|
96
99
|
try {
|
|
@@ -125,6 +128,9 @@ export class ClaudeSessionParser {
|
|
|
125
128
|
const text = this.extractUserMessageText(msgContent);
|
|
126
129
|
if (text) {
|
|
127
130
|
lastUserMessage = text;
|
|
131
|
+
if (!firstUserMessage) {
|
|
132
|
+
firstUserMessage = text;
|
|
133
|
+
}
|
|
128
134
|
}
|
|
129
135
|
} else {
|
|
130
136
|
isInterrupted = false;
|
|
@@ -144,6 +150,7 @@ export class ClaudeSessionParser {
|
|
|
144
150
|
lastEntryType,
|
|
145
151
|
isInterrupted,
|
|
146
152
|
lastUserMessage,
|
|
153
|
+
firstUserMessage,
|
|
147
154
|
};
|
|
148
155
|
}
|
|
149
156
|
|
|
@@ -340,9 +347,9 @@ export class ClaudeSessionParser {
|
|
|
340
347
|
if (!content) return undefined;
|
|
341
348
|
|
|
342
349
|
if (typeof content === 'string') {
|
|
343
|
-
const
|
|
344
|
-
if (role === 'user' && isNoiseMessage(
|
|
345
|
-
return
|
|
350
|
+
const cleaned = stripHarnessTags(content);
|
|
351
|
+
if (role === 'user' && isNoiseMessage(cleaned)) return undefined;
|
|
352
|
+
return cleaned || undefined;
|
|
346
353
|
}
|
|
347
354
|
|
|
348
355
|
if (!Array.isArray(content)) return undefined;
|
|
@@ -351,8 +358,10 @@ export class ClaudeSessionParser {
|
|
|
351
358
|
|
|
352
359
|
for (const block of content) {
|
|
353
360
|
if (block.type === 'text' && block.text?.trim()) {
|
|
354
|
-
|
|
355
|
-
|
|
361
|
+
const cleaned = stripHarnessTags(block.text);
|
|
362
|
+
if (!cleaned) continue;
|
|
363
|
+
if (role === 'user' && isNoiseMessage(cleaned)) continue;
|
|
364
|
+
parts.push(cleaned);
|
|
356
365
|
} else if (block.type === 'tool_use' && verbose) {
|
|
357
366
|
const inputSummary = block.input?.file_path || block.input?.pattern || block.input?.command || '';
|
|
358
367
|
parts.push(`[Tool: ${block.name}]${inputSummary ? ' ' + inputSummary : ''}`);
|
|
@@ -367,6 +376,51 @@ export class ClaudeSessionParser {
|
|
|
367
376
|
}
|
|
368
377
|
}
|
|
369
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Tags whose entire block (including content) should be dropped — they are
|
|
381
|
+
* harness-injected prompt context (system reminders, hook output, command
|
|
382
|
+
* stdout), not meaningful conversation content.
|
|
383
|
+
*/
|
|
384
|
+
const HARNESS_DROP_TAGS = [
|
|
385
|
+
'system-reminder',
|
|
386
|
+
'local-command-stdout',
|
|
387
|
+
'local-command-stderr',
|
|
388
|
+
'user-prompt-submit-hook',
|
|
389
|
+
'command-stdout',
|
|
390
|
+
'command-stderr',
|
|
391
|
+
'bash-input',
|
|
392
|
+
'bash-stdout',
|
|
393
|
+
'bash-stderr',
|
|
394
|
+
'command-message',
|
|
395
|
+
] as const;
|
|
396
|
+
|
|
397
|
+
const HARNESS_DROP_RE = new RegExp(
|
|
398
|
+
`<(${HARNESS_DROP_TAGS.join('|')})>[\\s\\S]*?</\\1>`,
|
|
399
|
+
'g',
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
const COMMAND_INVOCATION_RE =
|
|
403
|
+
/<command-name>([^<]+)<\/command-name>(?:\s*<command-args>([\s\S]*?)<\/command-args>)?/g;
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Remove harness-injected XML blocks from message text and collapse
|
|
407
|
+
* <command-name>/<command-args> pairs into a "/name args" shorthand.
|
|
408
|
+
*
|
|
409
|
+
* Returns the cleaned, trimmed text. Returns an empty string if nothing
|
|
410
|
+
* survives stripping.
|
|
411
|
+
*/
|
|
412
|
+
function stripHarnessTags(text: string): string {
|
|
413
|
+
let out = text.replace(HARNESS_DROP_RE, '');
|
|
414
|
+
|
|
415
|
+
out = out.replace(COMMAND_INVOCATION_RE, (_match, rawName: string, rawArgs?: string) => {
|
|
416
|
+
const name = rawName.trim();
|
|
417
|
+
const args = rawArgs?.trim();
|
|
418
|
+
return args ? `${name} ${args}` : name;
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
return out.replace(/\n{3,}/g, '\n\n').trim();
|
|
422
|
+
}
|
|
423
|
+
|
|
370
424
|
/** Check if a message is noise (not a meaningful user intent). */
|
|
371
425
|
function isNoiseMessage(text: string): boolean {
|
|
372
426
|
return (
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape a string for safe use inside an AppleScript double-quoted string.
|
|
3
|
+
* Backslashes, double quotes, and newlines must be escaped.
|
|
4
|
+
*/
|
|
5
|
+
export function escapeAppleScript(text: string): string {
|
|
6
|
+
return text
|
|
7
|
+
.replace(/\\/g, '\\\\')
|
|
8
|
+
.replace(/"/g, '\\"')
|
|
9
|
+
.replace(/\r\n|\r|\n/g, '\\n');
|
|
10
|
+
}
|