@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
|
@@ -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
|
+
}
|
package/src/utils/session.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session File Utilities
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Uses
|
|
4
|
+
* Utilities for discovering session files and their birth times.
|
|
5
|
+
* Uses Node.js fs APIs to get birth timestamps without reading file contents.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import * as fs from 'fs';
|
|
8
9
|
import * as path from 'path';
|
|
9
|
-
import { execSync } from 'child_process';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Represents a session file with its birth time metadata.
|
|
@@ -29,63 +29,104 @@ export interface SessionFile {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* Combines all directory globs into one `stat` command to avoid per-directory exec overhead.
|
|
35
|
-
* Returns empty array if no directories have .jsonl files or command fails.
|
|
36
|
-
* resolvedCwd is left empty — the adapter must set it.
|
|
32
|
+
* Check whether a path exists and is a directory.
|
|
33
|
+
* Returns false on any error (missing path, permission denied, broken symlink, etc.).
|
|
37
34
|
*/
|
|
38
|
-
export function
|
|
39
|
-
|
|
35
|
+
export function isDirectory(p: string): boolean {
|
|
36
|
+
return safeStat(p)?.isDirectory() ?? false;
|
|
37
|
+
}
|
|
40
38
|
|
|
39
|
+
/**
|
|
40
|
+
* `fs.statSync` that swallows errors and returns `undefined` on failure.
|
|
41
|
+
* Callers can pull whichever fields they need (mtime, birthtime, ...).
|
|
42
|
+
*/
|
|
43
|
+
export function safeStat(filePath: string): fs.Stats | undefined {
|
|
41
44
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
: `stat --format='%W %n' ${globs} 2>/dev/null || true`;
|
|
45
|
+
return fs.statSync(filePath);
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
/**
|
|
52
|
+
* `fs.readFileSync` (utf-8) that swallows errors and returns `undefined`
|
|
53
|
+
* on failure. Use when an unreadable file should be skipped rather than
|
|
54
|
+
* raised.
|
|
55
|
+
*/
|
|
56
|
+
export function safeReadFile(filePath: string): string | undefined {
|
|
57
|
+
try {
|
|
58
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
50
63
|
|
|
51
|
-
|
|
64
|
+
/**
|
|
65
|
+
* `fs.readdirSync` that swallows errors and returns `[]` on failure.
|
|
66
|
+
* Useful when walking optional/transient directories where missing or
|
|
67
|
+
* unreadable entries should be skipped silently.
|
|
68
|
+
*/
|
|
69
|
+
export function safeReaddir(dir: string): string[] {
|
|
70
|
+
try {
|
|
71
|
+
return fs.readdirSync(dir);
|
|
52
72
|
} catch {
|
|
53
73
|
return [];
|
|
54
74
|
}
|
|
55
75
|
}
|
|
56
76
|
|
|
57
77
|
/**
|
|
58
|
-
*
|
|
78
|
+
* List entries in a directory that end with `.jsonl`. Returns `[]` on
|
|
79
|
+
* read errors. The result preserves directory order (no sorting).
|
|
59
80
|
*/
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
for (const rawLine of output.trim().split('\n')) {
|
|
64
|
-
const line = rawLine.trim();
|
|
65
|
-
if (!line) continue;
|
|
66
|
-
|
|
67
|
-
// Format: "<epoch_seconds> <filepath>"
|
|
68
|
-
const spaceIdx = line.indexOf(' ');
|
|
69
|
-
if (spaceIdx === -1) continue;
|
|
70
|
-
|
|
71
|
-
const epochStr = line.slice(0, spaceIdx);
|
|
72
|
-
const filePath = line.slice(spaceIdx + 1).trim();
|
|
73
|
-
|
|
74
|
-
const epochSeconds = parseInt(epochStr, 10);
|
|
75
|
-
if (!Number.isFinite(epochSeconds) || epochSeconds <= 0) continue;
|
|
81
|
+
export function listJsonl(dir: string): string[] {
|
|
82
|
+
return safeReaddir(dir).filter((name) => name.endsWith('.jsonl'));
|
|
83
|
+
}
|
|
76
84
|
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Get birth times for .jsonl session files across multiple directories.
|
|
87
|
+
*
|
|
88
|
+
* Enumerates each directory with readdirSync and stats each .jsonl file
|
|
89
|
+
* to get its birth time. No shell commands are used.
|
|
90
|
+
* Returns empty array if no directories have .jsonl files or reads fail.
|
|
91
|
+
* resolvedCwd is left empty — the adapter must set it.
|
|
92
|
+
*/
|
|
93
|
+
export function batchGetSessionFileBirthtimes(dirs: string[]): SessionFile[] {
|
|
94
|
+
if (dirs.length === 0) return [];
|
|
79
95
|
|
|
80
|
-
|
|
96
|
+
const results: SessionFile[] = [];
|
|
81
97
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
98
|
+
for (const dir of dirs) {
|
|
99
|
+
let entries: string[];
|
|
100
|
+
try {
|
|
101
|
+
entries = fs.readdirSync(dir);
|
|
102
|
+
} catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (!entry.endsWith('.jsonl')) continue;
|
|
108
|
+
|
|
109
|
+
const filePath = path.join(dir, entry);
|
|
110
|
+
|
|
111
|
+
let birthtimeMs: number;
|
|
112
|
+
try {
|
|
113
|
+
birthtimeMs = fs.statSync(filePath).birthtimeMs;
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!Number.isFinite(birthtimeMs) || birthtimeMs <= 0) continue;
|
|
119
|
+
|
|
120
|
+
const sessionId = entry.replace(/\.jsonl$/, '');
|
|
121
|
+
|
|
122
|
+
results.push({
|
|
123
|
+
sessionId,
|
|
124
|
+
filePath,
|
|
125
|
+
projectDir: dir,
|
|
126
|
+
birthtimeMs,
|
|
127
|
+
resolvedCwd: '',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
89
130
|
}
|
|
90
131
|
|
|
91
132
|
return results;
|