@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.
Files changed (57) hide show
  1. package/dist/AgentManager.d.ts +14 -1
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +38 -0
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/adapters/AgentAdapter.d.ts +66 -0
  6. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  7. package/dist/adapters/ClaudeCodeAdapter.d.ts +14 -1
  8. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  9. package/dist/adapters/ClaudeCodeAdapter.js +60 -0
  10. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  11. package/dist/adapters/CodexAdapter.d.ts +14 -1
  12. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  13. package/dist/adapters/CodexAdapter.js +105 -6
  14. package/dist/adapters/CodexAdapter.js.map +1 -1
  15. package/dist/adapters/GeminiCliAdapter.d.ts +9 -1
  16. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  17. package/dist/adapters/GeminiCliAdapter.js +77 -6
  18. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
  23. package/dist/terminal/TerminalFocusManager.js +5 -7
  24. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  25. package/dist/terminal/TtyWriter.d.ts.map +1 -1
  26. package/dist/terminal/TtyWriter.js +3 -12
  27. package/dist/terminal/TtyWriter.js.map +1 -1
  28. package/dist/utils/ClaudeSessionParser.d.ts +2 -0
  29. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
  30. package/dist/utils/ClaudeSessionParser.js +48 -5
  31. package/dist/utils/ClaudeSessionParser.js.map +1 -1
  32. package/dist/utils/applescript.d.ts +6 -0
  33. package/dist/utils/applescript.d.ts.map +1 -0
  34. package/dist/utils/applescript.js +14 -0
  35. package/dist/utils/applescript.js.map +1 -0
  36. package/dist/utils/session.d.ts +34 -5
  37. package/dist/utils/session.d.ts.map +1 -1
  38. package/dist/utils/session.js +90 -44
  39. package/dist/utils/session.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/AgentManager.ts +55 -3
  42. package/src/__tests__/AgentManager.test.ts +134 -2
  43. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +159 -3
  44. package/src/__tests__/adapters/CodexAdapter.test.ts +123 -3
  45. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +133 -0
  46. package/src/__tests__/utils/ClaudeSessionParser.test.ts +195 -0
  47. package/src/__tests__/utils/session.test.ts +79 -43
  48. package/src/adapters/AgentAdapter.ts +76 -0
  49. package/src/adapters/ClaudeCodeAdapter.ts +76 -2
  50. package/src/adapters/CodexAdapter.ts +126 -8
  51. package/src/adapters/GeminiCliAdapter.ts +102 -7
  52. package/src/index.ts +9 -1
  53. package/src/terminal/TerminalFocusManager.ts +1 -4
  54. package/src/terminal/TtyWriter.ts +1 -11
  55. package/src/utils/ClaudeSessionParser.ts +59 -5
  56. package/src/utils/applescript.ts +10 -0
  57. package/src/utils/session.ts +86 -45
@@ -81,6 +81,70 @@ export interface ConversationMessage {
81
81
  timestamp?: string;
82
82
  }
83
83
 
84
+ /**
85
+ * A historical session discovered on disk (running or not).
86
+ *
87
+ * Used by `listSessions` to surface enough context for a user to identify
88
+ * a session and resume it via the originating tool's resume command.
89
+ */
90
+ export interface SessionSummary {
91
+ /** Tool that produced this session */
92
+ type: AgentType;
93
+
94
+ /**
95
+ * ID accepted by the tool's resume command. Adapters MUST pass this
96
+ * through verbatim — no normalization, no encoding/decoding — so it
97
+ * round-trips into `claude --resume <id>` (and equivalents).
98
+ */
99
+ sessionId: string;
100
+
101
+ /** Working directory the session was started in (best-known value) */
102
+ cwd: string;
103
+
104
+ /**
105
+ * Trimmed first user message; empty string if none. Adapters apply
106
+ * the same noise-filter their existing parsers use (skip tool_result
107
+ * blocks, request-interruption notices, system-injected skill
108
+ * content). The CLI table renderer substitutes a placeholder for
109
+ * empty values; JSON output keeps the empty string raw.
110
+ */
111
+ firstUserMessage: string;
112
+
113
+ /** Last activity timestamp (from session content; falls back to file mtime) */
114
+ lastActive: Date;
115
+
116
+ /** Session start time (from session content; falls back to file birthtime/mtime) */
117
+ startedAt: Date;
118
+
119
+ /** Absolute path to the session file on disk (debug/diagnostics) */
120
+ sessionFilePath: string;
121
+ }
122
+
123
+ /**
124
+ * Filters passed by the CLI to {@link AgentAdapter.listSessions}.
125
+ *
126
+ * The CLI is the source of truth for filter defaults and semantics
127
+ * (e.g. cwd defaults to process.cwd(); --all clears it). Adapters apply
128
+ * the values they receive — they don't invent defaults.
129
+ */
130
+ export interface ListSessionsOptions {
131
+ /**
132
+ * Filter to sessions whose recorded cwd matches this path using strict
133
+ * equality (no prefix/ancestor matching in v1). Undefined = no cwd
134
+ * filter.
135
+ */
136
+ cwd?: string;
137
+
138
+ /**
139
+ * Filter to a single tool. Enforced by `AgentManager.listSessions`,
140
+ * which skips adapters whose `type` doesn't match. Adapters MAY
141
+ * ignore this field — by the time their `listSessions` runs, the
142
+ * type filter is already satisfied. Undefined = include every
143
+ * registered adapter.
144
+ */
145
+ type?: AgentType;
146
+ }
147
+
84
148
  /**
85
149
  * Agent Adapter Interface
86
150
  *
@@ -110,4 +174,16 @@ export interface AgentAdapter {
110
174
  * @returns Array of conversation messages
111
175
  */
112
176
  getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[];
177
+
178
+ /**
179
+ * Enumerate historical sessions for this tool from disk.
180
+ *
181
+ * Applies `opts.cwd` as a strict-equality filter when set. Returns
182
+ * {@link SessionSummary} entries unsorted; sorting and global filters
183
+ * are handled by `AgentManager` and the CLI.
184
+ *
185
+ * @param opts Filter options computed by the CLI
186
+ * @returns Array of sessions discovered on disk
187
+ */
188
+ listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]>;
113
189
  }
@@ -1,9 +1,16 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter';
3
+ import type {
4
+ AgentAdapter,
5
+ AgentInfo,
6
+ ProcessInfo,
7
+ ConversationMessage,
8
+ SessionSummary,
9
+ ListSessionsOptions,
10
+ } from './AgentAdapter';
4
11
  import { AgentStatus } from './AgentAdapter';
5
12
  import { listAgentProcesses, enrichProcesses } from '../utils/process';
6
- import { batchGetSessionFileBirthtimes } from '../utils/session';
13
+ import { batchGetSessionFileBirthtimes, isDirectory, listJsonl, safeReaddir, safeStat } from '../utils/session';
7
14
  import type { SessionFile } from '../utils/session';
8
15
  import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
9
16
  import { ClaudeSessionParser } from '../utils/ClaudeSessionParser';
@@ -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 { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter';
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
- let content: string;
330
- try {
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 { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter';
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
- let content: string;
445
- try {
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 { AgentAdapter, AgentType, AgentInfo, ProcessInfo, ConversationMessage } from './adapters/AgentAdapter';
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 trimmed = content.trim();
344
- if (role === 'user' && isNoiseMessage(trimmed)) return undefined;
345
- return trimmed || undefined;
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
- if (role === 'user' && isNoiseMessage(block.text.trim())) continue;
355
- parts.push(block.text.trim());
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
+ }