@ai-devkit/agent-manager 0.11.0 → 0.13.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.
@@ -289,6 +289,76 @@ describe('ClaudeCodeAdapter', () => {
289
289
  });
290
290
  });
291
291
 
292
+ it('should match via --resume <uuid> in command line and skip PID-file/legacy', async () => {
293
+ const sessionId = '0555f803-7eca-4fc6-a1e0-34dbf86b33b2';
294
+ const processes: ProcessInfo[] = [
295
+ {
296
+ pid: 41920,
297
+ command: `claude --resume ${sessionId}`,
298
+ cwd: '/project/resumed',
299
+ tty: 'ttys001',
300
+ startTime: new Date(),
301
+ },
302
+ ];
303
+ mockedListAgentProcesses.mockReturnValue(processes);
304
+ mockedEnrichProcesses.mockReturnValue(processes);
305
+
306
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-resume-'));
307
+ const projectsDir = path.join(tmpDir, 'projects');
308
+ const projDir = path.join(projectsDir, '-project-resumed');
309
+ fs.mkdirSync(projDir, { recursive: true });
310
+
311
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
312
+ fs.writeFileSync(jsonlPath, [
313
+ JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/resumed', message: { content: 'resumed conversation' } }),
314
+ JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
315
+ ].join('\n'));
316
+
317
+ (adapter as any).projectsDir = projectsDir;
318
+ (adapter as any).sessionsDir = path.join(tmpDir, 'sessions'); // empty — no PID file
319
+
320
+ const agents = await adapter.detectAgents();
321
+
322
+ // Legacy matching helpers must NOT have been consulted — resume match was authoritative
323
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
324
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
325
+
326
+ expect(agents).toHaveLength(1);
327
+ expect(agents[0]).toMatchObject({
328
+ type: 'claude',
329
+ pid: 41920,
330
+ sessionId,
331
+ projectPath: '/project/resumed',
332
+ });
333
+ expect(agents[0].summary).toContain('resumed conversation');
334
+
335
+ fs.rmSync(tmpDir, { recursive: true, force: true });
336
+ });
337
+
338
+ it('should fall through when --resume points to a JSONL that does not exist', async () => {
339
+ const processes: ProcessInfo[] = [
340
+ {
341
+ pid: 41921,
342
+ command: 'claude --resume aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
343
+ cwd: '/project/missing',
344
+ tty: 'ttys001',
345
+ startTime: new Date(),
346
+ },
347
+ ];
348
+ mockedListAgentProcesses.mockReturnValue(processes);
349
+ mockedEnrichProcesses.mockReturnValue(processes);
350
+
351
+ // No projects dir, no PID file → both matchers fail → process-only
352
+ (adapter as any).projectsDir = '/nonexistent';
353
+ (adapter as any).sessionsDir = '/nonexistent';
354
+
355
+ const agents = await adapter.detectAgents();
356
+
357
+ expect(agents).toHaveLength(1);
358
+ expect(agents[0].sessionId).toBe('pid-41921');
359
+ expect(agents[0].status).toBe(AgentStatus.IDLE);
360
+ });
361
+
292
362
  it('should use PID file for direct match and skip legacy matching for that process', async () => {
293
363
  const startTime = new Date();
294
364
  const processes: ProcessInfo[] = [
@@ -338,6 +408,194 @@ describe('ClaudeCodeAdapter', () => {
338
408
  fs.rmSync(tmpDir, { recursive: true, force: true });
339
409
  });
340
410
 
411
+ it('should prefer PID-file live status over JSONL-derived status and surface waitingFor in summary', async () => {
412
+ const startTime = new Date();
413
+ const processes: ProcessInfo[] = [
414
+ { pid: 55050, command: 'claude', cwd: '/project/wait', tty: 'ttys001', startTime },
415
+ ];
416
+ mockedListAgentProcesses.mockReturnValue(processes);
417
+ mockedEnrichProcesses.mockReturnValue(processes);
418
+
419
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-wait-'));
420
+ const sessionsDir = path.join(tmpDir, 'sessions');
421
+ const projectsDir = path.join(tmpDir, 'projects');
422
+ const projDir = path.join(projectsDir, '-project-wait');
423
+ fs.mkdirSync(sessionsDir, { recursive: true });
424
+ fs.mkdirSync(projDir, { recursive: true });
425
+
426
+ const sessionId = 'wait-session';
427
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
428
+ // JSONL trails with permission-mode → parser would resolve to UNKNOWN.
429
+ // PID file's live status must win.
430
+ fs.writeFileSync(jsonlPath, [
431
+ JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/wait', message: { content: '/reddit-commenter' } }),
432
+ JSON.stringify({ type: 'permission-mode', timestamp: new Date().toISOString(), permissionMode: 'default' }),
433
+ ].join('\n'));
434
+
435
+ fs.writeFileSync(
436
+ path.join(sessionsDir, '55050.json'),
437
+ JSON.stringify({
438
+ pid: 55050, sessionId, cwd: '/project/wait',
439
+ startedAt: startTime.getTime(),
440
+ kind: 'interactive', entrypoint: 'cli',
441
+ status: 'waiting', waitingFor: 'approve Read',
442
+ }),
443
+ );
444
+
445
+ (adapter as any).sessionsDir = sessionsDir;
446
+ (adapter as any).projectsDir = projectsDir;
447
+
448
+ const agents = await adapter.detectAgents();
449
+
450
+ expect(agents).toHaveLength(1);
451
+ expect(agents[0].status).toBe(AgentStatus.WAITING);
452
+ expect(agents[0].summary).toContain('/reddit-commenter');
453
+ expect(agents[0].summary).toContain('waiting for approve Read');
454
+
455
+ fs.rmSync(tmpDir, { recursive: true, force: true });
456
+ });
457
+
458
+ it('should resolve PID-file status: "idle" to AgentStatus.IDLE even when JSONL would say UNKNOWN', async () => {
459
+ const startTime = new Date();
460
+ const processes: ProcessInfo[] = [
461
+ { pid: 55051, command: 'claude', cwd: '/project/idle', tty: 'ttys001', startTime },
462
+ ];
463
+ mockedListAgentProcesses.mockReturnValue(processes);
464
+ mockedEnrichProcesses.mockReturnValue(processes);
465
+
466
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-idle-'));
467
+ const sessionsDir = path.join(tmpDir, 'sessions');
468
+ const projectsDir = path.join(tmpDir, 'projects');
469
+ const projDir = path.join(projectsDir, '-project-idle');
470
+ fs.mkdirSync(sessionsDir, { recursive: true });
471
+ fs.mkdirSync(projDir, { recursive: true });
472
+
473
+ const sessionId = 'idle-session';
474
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
475
+ fs.writeFileSync(jsonlPath, [
476
+ JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/idle', message: { content: 'hello' } }),
477
+ JSON.stringify({ type: 'permission-mode', timestamp: new Date().toISOString(), permissionMode: 'default' }),
478
+ ].join('\n'));
479
+
480
+ fs.writeFileSync(
481
+ path.join(sessionsDir, '55051.json'),
482
+ JSON.stringify({
483
+ pid: 55051, sessionId, cwd: '/project/idle',
484
+ startedAt: startTime.getTime(),
485
+ kind: 'interactive', entrypoint: 'cli',
486
+ status: 'idle',
487
+ }),
488
+ );
489
+
490
+ (adapter as any).sessionsDir = sessionsDir;
491
+ (adapter as any).projectsDir = projectsDir;
492
+
493
+ const agents = await adapter.detectAgents();
494
+
495
+ expect(agents).toHaveLength(1);
496
+ expect(agents[0].status).toBe(AgentStatus.IDLE);
497
+ // No waitingFor in PID file → summary is just the last user message
498
+ expect(agents[0].summary).toBe('hello');
499
+
500
+ fs.rmSync(tmpDir, { recursive: true, force: true });
501
+ });
502
+
503
+ it('should fall back to JSONL-derived status when PID-file status is missing or unrecognized', async () => {
504
+ const startTime = new Date();
505
+ const processes: ProcessInfo[] = [
506
+ { pid: 55052, command: 'claude', cwd: '/project/legacy', tty: 'ttys001', startTime },
507
+ ];
508
+ mockedListAgentProcesses.mockReturnValue(processes);
509
+ mockedEnrichProcesses.mockReturnValue(processes);
510
+
511
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-nostat-'));
512
+ const sessionsDir = path.join(tmpDir, 'sessions');
513
+ const projectsDir = path.join(tmpDir, 'projects');
514
+ const projDir = path.join(projectsDir, '-project-legacy');
515
+ fs.mkdirSync(sessionsDir, { recursive: true });
516
+ fs.mkdirSync(projDir, { recursive: true });
517
+
518
+ const sessionId = 'legacy-session';
519
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
520
+ // Last entry is assistant → JSONL parser yields WAITING.
521
+ fs.writeFileSync(jsonlPath, [
522
+ JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/legacy', message: { content: 'do the thing' } }),
523
+ JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }),
524
+ ].join('\n'));
525
+
526
+ // PID file with unrecognized status string — adapter must ignore it and use parser
527
+ fs.writeFileSync(
528
+ path.join(sessionsDir, '55052.json'),
529
+ JSON.stringify({
530
+ pid: 55052, sessionId, cwd: '/project/legacy',
531
+ startedAt: startTime.getTime(),
532
+ kind: 'interactive', entrypoint: 'cli',
533
+ status: 'fantastical-future-state',
534
+ }),
535
+ );
536
+
537
+ (adapter as any).sessionsDir = sessionsDir;
538
+ (adapter as any).projectsDir = projectsDir;
539
+
540
+ const agents = await adapter.detectAgents();
541
+
542
+ expect(agents).toHaveLength(1);
543
+ expect(agents[0].status).toBe(AgentStatus.WAITING);
544
+
545
+ fs.rmSync(tmpDir, { recursive: true, force: true });
546
+ });
547
+
548
+ it('should pick up live status from PID file even when matched via --resume', async () => {
549
+ const sessionId = 'aaaaaaaa-1111-2222-3333-444444444444';
550
+ const startTime = new Date();
551
+ const processes: ProcessInfo[] = [
552
+ {
553
+ pid: 55053,
554
+ command: `claude --resume ${sessionId}`,
555
+ cwd: '/project/resume-wait',
556
+ tty: 'ttys001',
557
+ startTime,
558
+ },
559
+ ];
560
+ mockedListAgentProcesses.mockReturnValue(processes);
561
+ mockedEnrichProcesses.mockReturnValue(processes);
562
+
563
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-resume-wait-'));
564
+ const sessionsDir = path.join(tmpDir, 'sessions');
565
+ const projectsDir = path.join(tmpDir, 'projects');
566
+ const projDir = path.join(projectsDir, '-project-resume-wait');
567
+ fs.mkdirSync(sessionsDir, { recursive: true });
568
+ fs.mkdirSync(projDir, { recursive: true });
569
+
570
+ const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
571
+ fs.writeFileSync(jsonlPath, [
572
+ JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/resume-wait', message: { content: 'resumed work' } }),
573
+ JSON.stringify({ type: 'permission-mode', timestamp: new Date().toISOString(), permissionMode: 'default' }),
574
+ ].join('\n'));
575
+
576
+ fs.writeFileSync(
577
+ path.join(sessionsDir, '55053.json'),
578
+ JSON.stringify({
579
+ pid: 55053, sessionId, cwd: '/project/resume-wait',
580
+ startedAt: startTime.getTime(),
581
+ kind: 'interactive', entrypoint: 'cli',
582
+ status: 'waiting', waitingFor: 'approve Bash',
583
+ }),
584
+ );
585
+
586
+ (adapter as any).sessionsDir = sessionsDir;
587
+ (adapter as any).projectsDir = projectsDir;
588
+
589
+ const agents = await adapter.detectAgents();
590
+
591
+ expect(agents).toHaveLength(1);
592
+ expect(agents[0].status).toBe(AgentStatus.WAITING);
593
+ expect(agents[0].summary).toContain('resumed work');
594
+ expect(agents[0].summary).toContain('waiting for approve Bash');
595
+
596
+ fs.rmSync(tmpDir, { recursive: true, force: true });
597
+ });
598
+
341
599
  it('should fall back to process-only when direct-matched JSONL becomes unreadable', async () => {
342
600
  const startTime = new Date();
343
601
  const processes: ProcessInfo[] = [
@@ -844,7 +1102,7 @@ describe('ClaudeCodeAdapter', () => {
844
1102
  };
845
1103
 
846
1104
  const writeJsonl = (cwd: string, sessionId: string) => {
847
- const encoded = cwd.replace(/\//g, '-');
1105
+ const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-');
848
1106
  const projDir = path.join(projectsDir, encoded);
849
1107
  fs.mkdirSync(projDir, { recursive: true });
850
1108
  const filePath = path.join(projDir, `${sessionId}.jsonl`);
@@ -979,6 +1237,44 @@ describe('ClaudeCodeAdapter', () => {
979
1237
  });
980
1238
  });
981
1239
 
1240
+ describe('getProjectDir', () => {
1241
+ const encode = (cwd: string) => (adapter as any).getProjectDir(cwd) as string;
1242
+
1243
+ it('should replace path separators with hyphens', () => {
1244
+ const expected = path.join((adapter as any).projectsDir, '-Users-foo-bar');
1245
+ expect(encode('/Users/foo/bar')).toBe(expected);
1246
+ });
1247
+
1248
+ it('should encode underscores as hyphens (matches Claude Code CLI)', () => {
1249
+ const expected = path.join((adapter as any).projectsDir, '-Users-foo-my-project');
1250
+ expect(encode('/Users/foo/my_project')).toBe(expected);
1251
+ });
1252
+
1253
+ it('should encode dots as hyphens', () => {
1254
+ const expected = path.join((adapter as any).projectsDir, '-Users-foo--worktrees-x');
1255
+ expect(encode('/Users/foo/.worktrees/x')).toBe(expected);
1256
+ });
1257
+
1258
+ it('should collide paths that differ only in non-alphanumeric chars', () => {
1259
+ // The encoding is intentionally lossy — callers must
1260
+ // disambiguate via session JSONL contents, not dir name.
1261
+ expect(encode('/a/b_c')).toBe(encode('/a/b-c'));
1262
+ expect(encode('/a/b_c')).toBe(encode('/a/b.c'));
1263
+ });
1264
+
1265
+ it('should resolve to a real session dir when cwd contains underscores', () => {
1266
+ const cwd = '/Users/foo/my_project';
1267
+ const projectsDir = (adapter as any).projectsDir as string;
1268
+ const expectedDir = path.join(projectsDir, '-Users-foo-my-project');
1269
+ fs.mkdirSync(expectedDir, { recursive: true });
1270
+ const sessionFile = path.join(expectedDir, 'session-underscore.jsonl');
1271
+ fs.writeFileSync(sessionFile, '');
1272
+
1273
+ expect(encode(cwd)).toBe(expectedDir);
1274
+ expect(fs.existsSync(path.join(encode(cwd), 'session-underscore.jsonl'))).toBe(true);
1275
+ });
1276
+ });
1277
+
982
1278
  describe('readSession', () => {
983
1279
  it('should parse session file with timestamps, cwd, and entry type', () => {
984
1280
  const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser);