runwork 0.8.4 → 0.9.1

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.
@@ -210,9 +210,180 @@ describe('ClaudeCodeAdapter.readVersion', () => {
210
210
  });
211
211
  });
212
212
  describe('ClaudeCodeAdapter.readSkillUsage', () => {
213
- it('returns null when no skills directory exists', async () => {
214
- vi.mocked(existsSync).mockReturnValue(false);
213
+ it('returns null when projects dir does not exist', async () => {
214
+ mockExistsPaths([CLAUDE_DIR]);
215
+ const usage = await adapter.readSkillUsage(null);
216
+ expect(usage).toBeNull();
217
+ });
218
+ it('detects Path A: Skill tool calls in assistant messages', async () => {
219
+ mockExistsPaths([CLAUDE_DIR, PROJECTS_DIR]);
220
+ vi.mocked(readdirSync).mockImplementation((p) => {
221
+ if (p === PROJECTS_DIR)
222
+ return ['cwd'];
223
+ if (p === `${PROJECTS_DIR}/cwd`)
224
+ return ['s.jsonl'];
225
+ return [];
226
+ });
227
+ vi.mocked(statSync).mockReturnValue({ mtimeMs: 2_000_000_000_000 });
228
+ vi.mocked(readFileSync).mockReturnValue([
229
+ JSON.stringify({ type: 'user', timestamp: '2026-04-13T22:00:00.000Z', message: { role: 'user', content: 'use marketing skill' } }),
230
+ JSON.stringify({ type: 'assistant', timestamp: '2026-04-13T22:00:05.000Z', message: { role: 'assistant', content: [
231
+ { type: 'tool_use', name: 'Skill', input: { skill: 'marketing' }, id: 'toolu_1', caller: { type: 'direct' } },
232
+ ] } }),
233
+ JSON.stringify({ type: 'assistant', timestamp: '2026-04-13T22:01:00.000Z', message: { role: 'assistant', content: [
234
+ { type: 'tool_use', name: 'Skill', input: { skill: 'superpowers:brainstorming', args: 'generate ideas' }, id: 'toolu_2', caller: { type: 'direct' } },
235
+ ] } }),
236
+ JSON.stringify({ type: 'assistant', timestamp: '2026-04-13T22:02:00.000Z', message: { role: 'assistant', content: [
237
+ { type: 'tool_use', name: 'Skill', input: { skill: 'marketing' }, id: 'toolu_3', caller: { type: 'direct' } },
238
+ ] } }),
239
+ ].join('\n'));
240
+ const usage = await adapter.readSkillUsage(null);
241
+ expect(usage).not.toBeNull();
242
+ expect(usage).toHaveLength(2);
243
+ const marketing = usage.find(s => s.skillName === 'marketing');
244
+ const brainstorming = usage.find(s => s.skillName === 'superpowers:brainstorming');
245
+ expect(marketing.count).toBe(2);
246
+ expect(brainstorming.count).toBe(1);
247
+ expect(marketing.lastUsedAt).toBe('2026-04-13T22:02:00.000Z');
248
+ });
249
+ it('detects Path B: slash commands followed by skill content injection', async () => {
250
+ mockExistsPaths([CLAUDE_DIR, PROJECTS_DIR]);
251
+ vi.mocked(readdirSync).mockImplementation((p) => {
252
+ if (p === PROJECTS_DIR)
253
+ return ['cwd'];
254
+ if (p === `${PROJECTS_DIR}/cwd`)
255
+ return ['s.jsonl'];
256
+ return [];
257
+ });
258
+ vi.mocked(statSync).mockReturnValue({ mtimeMs: 2_000_000_000_000 });
259
+ vi.mocked(readFileSync).mockReturnValue([
260
+ // Slash command invocation
261
+ JSON.stringify({ type: 'user', timestamp: '2026-04-13T22:00:00.000Z', message: { role: 'user', content: '<command-message>marketing</command-message>\n<command-name>/marketing</command-name>' } }),
262
+ // Skill content injection (confirms it was a skill, not a built-in)
263
+ JSON.stringify({ type: 'user', timestamp: '2026-04-13T22:00:01.000Z', message: { role: 'user', content: 'Base directory for this skill: /Users/test/.claude/skills/marketing\n\n# Marketing Skill\nhelp with marketing...' } }),
264
+ ].join('\n'));
265
+ const usage = await adapter.readSkillUsage(null);
266
+ expect(usage).not.toBeNull();
267
+ expect(usage).toHaveLength(1);
268
+ expect(usage[0].skillName).toBe('marketing');
269
+ expect(usage[0].count).toBe(1);
270
+ });
271
+ it('does NOT count slash commands that are built-in (no skill content follows)', async () => {
272
+ mockExistsPaths([CLAUDE_DIR, PROJECTS_DIR]);
273
+ vi.mocked(readdirSync).mockImplementation((p) => {
274
+ if (p === PROJECTS_DIR)
275
+ return ['cwd'];
276
+ if (p === `${PROJECTS_DIR}/cwd`)
277
+ return ['s.jsonl'];
278
+ return [];
279
+ });
280
+ vi.mocked(statSync).mockReturnValue({ mtimeMs: 2_000_000_000_000 });
281
+ vi.mocked(readFileSync).mockReturnValue([
282
+ JSON.stringify({ type: 'user', timestamp: '2026-04-13T22:00:00.000Z', message: { role: 'user', content: '<command-name>/clear</command-name>\n <command-message>clear</command-message>\n <command-args></command-args>' } }),
283
+ // Next message is a normal user message, not skill content
284
+ JSON.stringify({ type: 'user', timestamp: '2026-04-13T22:01:00.000Z', message: { role: 'user', content: 'help me with something' } }),
285
+ ].join('\n'));
215
286
  const usage = await adapter.readSkillUsage(null);
216
287
  expect(usage).toBeNull();
217
288
  });
289
+ it('filters skill invocations by timestamp', async () => {
290
+ const sinceIso = '2026-04-13T22:00:00.000Z';
291
+ mockExistsPaths([CLAUDE_DIR, PROJECTS_DIR]);
292
+ vi.mocked(readdirSync).mockImplementation((p) => {
293
+ if (p === PROJECTS_DIR)
294
+ return ['cwd'];
295
+ if (p === `${PROJECTS_DIR}/cwd`)
296
+ return ['s.jsonl'];
297
+ return [];
298
+ });
299
+ vi.mocked(statSync).mockReturnValue({ mtimeMs: 2_000_000_000_000 });
300
+ vi.mocked(readFileSync).mockReturnValue([
301
+ // Before sync, should be ignored
302
+ JSON.stringify({ type: 'assistant', timestamp: '2026-04-13T21:00:00.000Z', message: { role: 'assistant', content: [
303
+ { type: 'tool_use', name: 'Skill', input: { skill: 'old-skill' }, id: 'toolu_old' },
304
+ ] } }),
305
+ // After sync, should be counted
306
+ JSON.stringify({ type: 'assistant', timestamp: '2026-04-13T23:00:00.000Z', message: { role: 'assistant', content: [
307
+ { type: 'tool_use', name: 'Skill', input: { skill: 'new-skill' }, id: 'toolu_new' },
308
+ ] } }),
309
+ ].join('\n'));
310
+ const usage = await adapter.readSkillUsage(sinceIso);
311
+ expect(usage).not.toBeNull();
312
+ expect(usage).toHaveLength(1);
313
+ expect(usage[0].skillName).toBe('new-skill');
314
+ });
315
+ it('handles both Path A and Path B in the same session', async () => {
316
+ mockExistsPaths([CLAUDE_DIR, PROJECTS_DIR]);
317
+ vi.mocked(readdirSync).mockImplementation((p) => {
318
+ if (p === PROJECTS_DIR)
319
+ return ['cwd'];
320
+ if (p === `${PROJECTS_DIR}/cwd`)
321
+ return ['s.jsonl'];
322
+ return [];
323
+ });
324
+ vi.mocked(statSync).mockReturnValue({ mtimeMs: 2_000_000_000_000 });
325
+ vi.mocked(readFileSync).mockReturnValue([
326
+ // Path A: model-initiated
327
+ JSON.stringify({ type: 'assistant', timestamp: '2026-04-13T22:00:00.000Z', message: { role: 'assistant', content: [
328
+ { type: 'tool_use', name: 'Skill', input: { skill: 'marketing' }, id: 'toolu_1' },
329
+ ] } }),
330
+ // Path B: user-initiated slash command
331
+ JSON.stringify({ type: 'user', timestamp: '2026-04-13T22:05:00.000Z', message: { role: 'user', content: '<command-name>/seo-review</command-name>' } }),
332
+ JSON.stringify({ type: 'user', timestamp: '2026-04-13T22:05:01.000Z', message: { role: 'user', content: 'Base directory for this skill: /Users/test/.claude/skills/seo-review\n\n# SEO Review' } }),
333
+ ].join('\n'));
334
+ const usage = await adapter.readSkillUsage(null);
335
+ expect(usage).not.toBeNull();
336
+ expect(usage).toHaveLength(2);
337
+ expect(usage.find(s => s.skillName === 'marketing')).toBeDefined();
338
+ expect(usage.find(s => s.skillName === 'seo-review')).toBeDefined();
339
+ });
340
+ });
341
+ describe('ClaudeCodeAdapter.readUsageStats dedup', () => {
342
+ it('deduplicates entries with same message.id + requestId', async () => {
343
+ mockExistsPaths([CLAUDE_DIR, PROJECTS_DIR]);
344
+ vi.mocked(readdirSync).mockImplementation((p) => {
345
+ if (p === PROJECTS_DIR)
346
+ return ['cwd'];
347
+ if (p === `${PROJECTS_DIR}/cwd`)
348
+ return ['s.jsonl'];
349
+ return [];
350
+ });
351
+ vi.mocked(statSync).mockReturnValue({ mtimeMs: 2_000_000_000_000 });
352
+ vi.mocked(readFileSync).mockReturnValue([
353
+ // Two entries with same message.id + requestId (retransmission)
354
+ JSON.stringify({ type: 'assistant', timestamp: '2026-04-13T22:00:00.000Z', requestId: 'req_1',
355
+ message: { id: 'msg_1', role: 'assistant', content: [{ type: 'text', text: 'hi' }],
356
+ usage: { input_tokens: 100, output_tokens: 50 } } }),
357
+ JSON.stringify({ type: 'assistant', timestamp: '2026-04-13T22:00:01.000Z', requestId: 'req_1',
358
+ message: { id: 'msg_1', role: 'assistant', content: [{ type: 'text', text: 'hi' }],
359
+ usage: { input_tokens: 100, output_tokens: 50 } } }),
360
+ // Different message, should be counted
361
+ JSON.stringify({ type: 'user', timestamp: '2026-04-13T22:00:05.000Z', requestId: 'req_2',
362
+ message: { id: 'msg_2', role: 'user', content: 'hello' } }),
363
+ ].join('\n'));
364
+ const stats = await adapter.readUsageStats(null);
365
+ expect(stats).not.toBeNull();
366
+ expect(stats.messageCount).toBe(2); // 1 assistant (deduped) + 1 user
367
+ });
368
+ it('counts entries without message.id or requestId (no dedup possible)', async () => {
369
+ mockExistsPaths([CLAUDE_DIR, PROJECTS_DIR]);
370
+ vi.mocked(readdirSync).mockImplementation((p) => {
371
+ if (p === PROJECTS_DIR)
372
+ return ['cwd'];
373
+ if (p === `${PROJECTS_DIR}/cwd`)
374
+ return ['s.jsonl'];
375
+ return [];
376
+ });
377
+ vi.mocked(statSync).mockReturnValue({ mtimeMs: 2_000_000_000_000 });
378
+ vi.mocked(readFileSync).mockReturnValue([
379
+ // No message.id, both should count
380
+ JSON.stringify({ type: 'user', timestamp: '2026-04-13T22:00:00.000Z',
381
+ message: { role: 'user', content: 'hello' } }),
382
+ JSON.stringify({ type: 'user', timestamp: '2026-04-13T22:00:01.000Z',
383
+ message: { role: 'user', content: 'world' } }),
384
+ ].join('\n'));
385
+ const stats = await adapter.readUsageStats(null);
386
+ expect(stats).not.toBeNull();
387
+ expect(stats.messageCount).toBe(2);
388
+ });
218
389
  });
@@ -247,6 +247,99 @@ describe('CodexAdapter (codex).readUsageStats', () => {
247
247
  expect(result.messageCount).toBe(1);
248
248
  });
249
249
  });
250
+ describe('CodexAdapter (codex).readSkillUsage', () => {
251
+ let adapter;
252
+ beforeEach(() => {
253
+ vi.restoreAllMocks();
254
+ adapter = createCodexAdapter();
255
+ });
256
+ it('parses skill names from session_meta.instructions in rollout JSONL', async () => {
257
+ const sessionsDir = '/mock/home/.codex/sessions';
258
+ const rolloutPath = `${sessionsDir}/2026/04/20/rollout-test.jsonl`;
259
+ existsSync.mockImplementation((p) => {
260
+ if (p === sessionsDir)
261
+ return true;
262
+ return false;
263
+ });
264
+ // Mock the filesystem tree: sessions/2026/04/20/rollout-test.jsonl
265
+ const { readdirSync, statSync } = await import('fs');
266
+ readdirSync.mockImplementation((p) => {
267
+ if (p === sessionsDir)
268
+ return ['2026'];
269
+ if (p === `${sessionsDir}/2026`)
270
+ return ['04'];
271
+ if (p === `${sessionsDir}/2026/04`)
272
+ return ['20'];
273
+ if (p === `${sessionsDir}/2026/04/20`)
274
+ return ['rollout-test.jsonl'];
275
+ return [];
276
+ });
277
+ statSync.mockImplementation((p) => {
278
+ if (p.endsWith('.jsonl'))
279
+ return { mtimeMs: 2_000_000_000_000, isDirectory: () => false };
280
+ return { isDirectory: () => true, mtimeMs: 0 };
281
+ });
282
+ const sessionMeta = JSON.stringify({
283
+ timestamp: '2026-04-20T10:00:00.000Z',
284
+ type: 'session_meta',
285
+ payload: {
286
+ id: 'test-id',
287
+ instructions: `## Skills
288
+ A skill is a set of local instructions.
289
+ ### Available skills
290
+ - write-cold-email: Write a cold email for outreach (file: /home/.codex/skills/write-cold-email/SKILL.md)
291
+ - marketing-task-tracker: Track marketing tasks (file: /home/.codex/skills/marketing-task-tracker/SKILL.md)
292
+ - skill-creator: Guide for creating effective skills (file: /home/.codex/skills/.system/skill-creator/SKILL.md)
293
+ - skill-installer: Install Codex skills (file: /home/.codex/skills/.system/skill-installer/SKILL.md)
294
+ ### How to use skills`,
295
+ },
296
+ });
297
+ readFileSync.mockReturnValue(sessionMeta);
298
+ const usage = await adapter.readSkillUsage(null);
299
+ expect(usage).not.toBeNull();
300
+ // Should find 2 skills (skill-creator and skill-installer are filtered as system skills)
301
+ expect(usage).toHaveLength(2);
302
+ expect(usage.find(s => s.skillName === 'write-cold-email')).toBeDefined();
303
+ expect(usage.find(s => s.skillName === 'marketing-task-tracker')).toBeDefined();
304
+ // System skills should be excluded
305
+ expect(usage.find(s => s.skillName === 'skill-creator')).toBeUndefined();
306
+ });
307
+ it('returns null when sessions directory does not exist', async () => {
308
+ existsSync.mockReturnValue(false);
309
+ const usage = await adapter.readSkillUsage(null);
310
+ expect(usage).toBeNull();
311
+ });
312
+ it('filters rollout files by mtime', async () => {
313
+ const sessionsDir = '/mock/home/.codex/sessions';
314
+ const sinceIso = '2026-04-20T12:00:00.000Z';
315
+ existsSync.mockImplementation((p) => {
316
+ if (p === sessionsDir)
317
+ return true;
318
+ return false;
319
+ });
320
+ const { readdirSync, statSync } = await import('fs');
321
+ readdirSync.mockImplementation((p) => {
322
+ if (p === sessionsDir)
323
+ return ['2026'];
324
+ if (p === `${sessionsDir}/2026`)
325
+ return ['04'];
326
+ if (p === `${sessionsDir}/2026/04`)
327
+ return ['20'];
328
+ if (p === `${sessionsDir}/2026/04/20`)
329
+ return ['old.jsonl'];
330
+ return [];
331
+ });
332
+ // File mtime is BEFORE sinceMs
333
+ statSync.mockImplementation((p) => {
334
+ if (p.endsWith('.jsonl'))
335
+ return { mtimeMs: Date.parse('2026-04-20T10:00:00Z'), isDirectory: () => false };
336
+ return { isDirectory: () => true, mtimeMs: 0 };
337
+ });
338
+ const usage = await adapter.readSkillUsage(sinceIso);
339
+ expect(usage).toBeNull();
340
+ expect(readFileSync).not.toHaveBeenCalled();
341
+ });
342
+ });
250
343
  describe('CodexAdapter (codex).readVersion', () => {
251
344
  let adapter;
252
345
  beforeEach(() => {
@@ -319,6 +319,9 @@ export class ClaudeCodeAdapter {
319
319
  let outputTokens = 0;
320
320
  let cacheReadTokens = 0;
321
321
  let latestActivityMs = 0;
322
+ // Dedup: ccusage uses ${message.id}:${requestId} to drop retransmissions.
323
+ // Entries without both fields pass through undeduped (same behavior).
324
+ const seenEntries = new Set();
322
325
  let cwdEntries;
323
326
  try {
324
327
  cwdEntries = readdirSync(projectsDir);
@@ -377,6 +380,16 @@ export class ClaudeCodeAdapter {
377
380
  latestActivityMs = ts;
378
381
  const type = entry.type;
379
382
  if (type === 'user' || type === 'assistant') {
383
+ // Dedup by message.id + requestId (same approach as ccusage)
384
+ const msg = entry.message;
385
+ const msgId = msg && typeof msg === 'object' ? msg.id : undefined;
386
+ const reqId = entry.requestId;
387
+ if (typeof msgId === 'string' && typeof reqId === 'string') {
388
+ const dedupKey = `${msgId}:${reqId}`;
389
+ if (seenEntries.has(dedupKey))
390
+ continue;
391
+ seenEntries.add(dedupKey);
392
+ }
380
393
  messageCount++;
381
394
  }
382
395
  if (type === 'assistant') {
@@ -427,36 +440,144 @@ export class ClaudeCodeAdapter {
427
440
  }
428
441
  }
429
442
  async readSkillUsage(lastSyncAt) {
443
+ // Walk the same JSONL files as readUsageStats and detect actual skill
444
+ // invocations. Two verified paths exist in the data:
445
+ //
446
+ // Path A (model-initiated): assistant message contains a tool_use block
447
+ // with name === "Skill" and input.skill === "<skill-name>".
448
+ //
449
+ // Path B (user-initiated slash command): user message contains
450
+ // <command-name>/xxx</command-name>, and the NEXT user message starts
451
+ // with "Base directory for this skill:" (confirming the harness treated
452
+ // it as a skill, not a built-in command).
453
+ //
454
+ // MCP skill tools (mcp__*__skill_*) are listed in deferred-tool blocks
455
+ // but are never actually invoked in any observed JSONL data.
430
456
  try {
431
- const results = [];
457
+ const projectsDir = join(homedir(), '.claude', 'projects');
458
+ if (!existsSync(projectsDir))
459
+ return null;
432
460
  const sinceMs = lastSyncAt ? new Date(lastSyncAt).getTime() : 0;
433
- const skillDirs = [
434
- join(homedir(), '.claude', 'skills'),
435
- join(process.cwd(), '.claude', 'skills'),
436
- ];
437
- for (const dir of skillDirs) {
438
- if (!existsSync(dir))
461
+ const skillCounts = new Map();
462
+ const recordSkill = (name, ts) => {
463
+ const existing = skillCounts.get(name);
464
+ if (existing) {
465
+ existing.count++;
466
+ if (ts > existing.lastTs)
467
+ existing.lastTs = ts;
468
+ }
469
+ else {
470
+ skillCounts.set(name, { count: 1, lastTs: ts });
471
+ }
472
+ };
473
+ let cwdEntries;
474
+ try {
475
+ cwdEntries = readdirSync(projectsDir);
476
+ }
477
+ catch {
478
+ return null;
479
+ }
480
+ for (const cwd of cwdEntries) {
481
+ const cwdPath = join(projectsDir, cwd);
482
+ let files;
483
+ try {
484
+ files = readdirSync(cwdPath);
485
+ }
486
+ catch {
439
487
  continue;
440
- for (const entry of readdirSync(dir)) {
441
- if (entry === 'runwork')
488
+ }
489
+ for (const file of files) {
490
+ if (!file.endsWith('.jsonl'))
442
491
  continue;
443
- const skillDir = join(dir, entry);
492
+ const filePath = join(cwdPath, file);
493
+ let fileStat;
444
494
  try {
445
- const stat = statSync(skillDir);
446
- if (stat.mtimeMs > sinceMs) {
447
- results.push({
448
- skillName: entry,
449
- count: 1,
450
- lastUsedAt: new Date(stat.mtimeMs).toISOString(),
451
- });
452
- }
495
+ fileStat = statSync(filePath);
496
+ }
497
+ catch {
498
+ continue;
499
+ }
500
+ if (fileStat.mtimeMs <= sinceMs)
501
+ continue;
502
+ let content;
503
+ try {
504
+ content = readFileSync(filePath, 'utf-8');
453
505
  }
454
506
  catch {
455
507
  continue;
456
508
  }
509
+ // Track pending slash command for Path B lookahead
510
+ let pendingCommand = null;
511
+ for (const line of content.split('\n')) {
512
+ if (!line)
513
+ continue;
514
+ let entry;
515
+ try {
516
+ entry = JSON.parse(line);
517
+ }
518
+ catch {
519
+ continue;
520
+ }
521
+ const tsRaw = entry.timestamp;
522
+ if (typeof tsRaw !== 'string')
523
+ continue;
524
+ const ts = Date.parse(tsRaw);
525
+ if (!Number.isFinite(ts) || ts <= sinceMs)
526
+ continue;
527
+ const type = entry.type;
528
+ // Path A: Skill tool calls in assistant messages
529
+ if (type === 'assistant') {
530
+ const msg = entry.message;
531
+ if (msg && typeof msg === 'object') {
532
+ const blocks = Array.isArray(msg.content)
533
+ ? msg.content
534
+ : [];
535
+ for (const block of blocks) {
536
+ if (block && block.type === 'tool_use' && block.name === 'Skill') {
537
+ const input = block.input;
538
+ if (input && typeof input.skill === 'string') {
539
+ recordSkill(input.skill, ts);
540
+ }
541
+ }
542
+ }
543
+ }
544
+ }
545
+ // Path B: slash command -> skill content injection
546
+ if (type === 'user') {
547
+ const msg = entry.message;
548
+ const msgContent = msg?.content;
549
+ const text = typeof msgContent === 'string' ? msgContent : '';
550
+ // Check if this is a skill content injection following a slash command
551
+ if (pendingCommand && text.startsWith('Base directory for this skill:')) {
552
+ recordSkill(pendingCommand.name, pendingCommand.ts);
553
+ pendingCommand = null;
554
+ continue;
555
+ }
556
+ // Reset pending on any user message that isn't skill content
557
+ if (pendingCommand && type === 'user') {
558
+ pendingCommand = null;
559
+ }
560
+ // Check for slash command pattern
561
+ const match = text.match(/<command-name>\/?([^<]+)<\/command-name>/);
562
+ if (match) {
563
+ const cmdName = match[1].replace(/^\//, '');
564
+ pendingCommand = { name: cmdName, ts };
565
+ }
566
+ }
567
+ }
457
568
  }
458
569
  }
459
- return results.length > 0 ? results : null;
570
+ if (skillCounts.size === 0)
571
+ return null;
572
+ const results = [];
573
+ for (const [skillName, { count, lastTs }] of skillCounts) {
574
+ results.push({
575
+ skillName,
576
+ count,
577
+ lastUsedAt: new Date(lastTs).toISOString(),
578
+ });
579
+ }
580
+ return results;
460
581
  }
461
582
  catch {
462
583
  return null;
@@ -1,4 +1,4 @@
1
- import type { AgentAdapter, AgentConfigOverride, AgentUsageStats, CleanupManifest, McpServerEntry, SkillFile } from './types.js';
1
+ import type { AgentAdapter, AgentConfigOverride, AgentUsageStats, CleanupManifest, McpServerEntry, SkillFile, SkillUsageEntry } from './types.js';
2
2
  /**
3
3
  * Collect every `<session>/<org>/memory/CLAUDE.md` path under the Cowork
4
4
  * base directory. Claude Desktop loads this file on every conversation in
@@ -39,6 +39,7 @@ export declare class ClaudeDesktopAdapter implements AgentAdapter {
39
39
  cleanup(_scope: 'project' | 'user', _manifest?: CleanupManifest): Promise<void>;
40
40
  readUsageStats(lastSyncAt: string | null): Promise<AgentUsageStats | null>;
41
41
  readVersion(): Promise<string | null>;
42
+ readSkillUsage(lastSyncAt: string | null): Promise<SkillUsageEntry[] | null>;
42
43
  /**
43
44
  * Walk the org/user/session directory tree and invoke callback for each session JSON.
44
45
  */
@@ -578,6 +578,63 @@ export class ClaudeDesktopAdapter {
578
578
  return null;
579
579
  }
580
580
  }
581
+ async readSkillUsage(lastSyncAt) {
582
+ // Cowork session JSON files contain enabledMcpTools, an object whose keys
583
+ // are MCP tool names. Tools matching skill_* patterns indicate which skills
584
+ // were loaded (reach, not invocation). This is the best we can get from
585
+ // local data. For actual invocation data, OTEL or the Enterprise Analytics
586
+ // API are needed.
587
+ try {
588
+ const baseDir = getCoworkBaseDir();
589
+ if (!existsSync(baseDir))
590
+ return null;
591
+ const sinceMs = lastSyncAt ? new Date(lastSyncAt).getTime() : 0;
592
+ const skillCounts = new Map();
593
+ const agentSessionsDir = baseDir;
594
+ this.walkSessionDirs(agentSessionsDir, sinceMs, (session) => {
595
+ const sessionTime = session.lastActivityAt ?? session.createdAt ?? 0;
596
+ if (sessionTime <= sinceMs)
597
+ return;
598
+ const mcpTools = session.enabledMcpTools;
599
+ if (!mcpTools || typeof mcpTools !== 'object')
600
+ return;
601
+ for (const toolName of Object.keys(mcpTools)) {
602
+ // Match patterns like "skill_Blog_Writing_Specialist" or
603
+ // "mcp__Runwork__skill_Write_Cold_Email"
604
+ const skillMatch = toolName.match(/skill_([A-Za-z0-9_]+)/);
605
+ if (!skillMatch)
606
+ continue;
607
+ // Convert Tool_Name_Format to readable name
608
+ const rawName = skillMatch[1].replace(/_/g, ' ').trim();
609
+ if (!rawName)
610
+ continue;
611
+ const existing = skillCounts.get(rawName);
612
+ if (existing) {
613
+ existing.count++;
614
+ if (sessionTime > existing.lastTs)
615
+ existing.lastTs = sessionTime;
616
+ }
617
+ else {
618
+ skillCounts.set(rawName, { count: 1, lastTs: sessionTime });
619
+ }
620
+ }
621
+ });
622
+ if (skillCounts.size === 0)
623
+ return null;
624
+ const results = [];
625
+ for (const [skillName, { count, lastTs }] of skillCounts) {
626
+ results.push({
627
+ skillName,
628
+ count,
629
+ lastUsedAt: new Date(lastTs).toISOString(),
630
+ });
631
+ }
632
+ return results;
633
+ }
634
+ catch {
635
+ return null;
636
+ }
637
+ }
581
638
  /**
582
639
  * Walk the org/user/session directory tree and invoke callback for each session JSON.
583
640
  */
@@ -1,4 +1,4 @@
1
- import type { AgentAdapter, AgentConfigOverride, AgentUsageStats, CleanupManifest, McpServerEntry, SkillFile } from './types.js';
1
+ import type { AgentAdapter, AgentConfigOverride, AgentUsageStats, CleanupManifest, McpServerEntry, SkillFile, SkillUsageEntry } from './types.js';
2
2
  export declare class CodexAdapter implements AgentAdapter {
3
3
  name: string;
4
4
  slug: string;
@@ -14,4 +14,31 @@ export declare class CodexAdapter implements AgentAdapter {
14
14
  cleanup(scope: 'project' | 'user', manifest?: CleanupManifest): Promise<void>;
15
15
  readUsageStats(lastSyncAt: string | null): Promise<AgentUsageStats | null>;
16
16
  readVersion(): Promise<string | null>;
17
+ readSkillUsage(lastSyncAt: string | null): Promise<SkillUsageEntry[] | null>;
18
+ /** Parse a rollout JSONL file for skill data */
19
+ private parseRolloutForSkills;
20
+ /**
21
+ * Register a workspace directory in the Codex desktop app's project list.
22
+ * Adds the path to electron-saved-workspace-roots, project-order, and
23
+ * electron-workspace-root-labels. Skips active-workspace-roots to avoid
24
+ * force-switching the user's active project.
25
+ *
26
+ * Returns 'written' if changes were made, 'already_registered' if the
27
+ * path was already present, or 'app_running' if Codex is open and would
28
+ * overwrite our changes.
29
+ */
30
+ registerDesktopWorkspace(workspacePath: string, label: string): 'written' | 'already_registered' | 'app_running';
31
+ }
32
+ /**
33
+ * Codex Desktop (OpenAI's GUI app) reads its config from the same `~/.codex`
34
+ * directory as the Codex CLI, so every sync/cleanup behavior inherits from
35
+ * CodexAdapter unchanged. Only the identity (slug/name) and the installation
36
+ * detection differ -- the desktop app is detected by application bundle
37
+ * presence, not by a `codex` binary on PATH.
38
+ */
39
+ export declare class CodexDesktopAdapter extends CodexAdapter {
40
+ name: string;
41
+ slug: string;
42
+ detect(): Promise<boolean>;
43
+ readUsageStats(): Promise<null>;
17
44
  }