runwork 0.9.0 → 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.
- package/dist/agents/__tests__/claude-code-stats.test.js +173 -2
- package/dist/agents/__tests__/codex-stats.test.js +93 -0
- package/dist/agents/claude-code.js +140 -19
- package/dist/agents/claude-desktop.d.ts +2 -1
- package/dist/agents/claude-desktop.js +57 -0
- package/dist/agents/codex.d.ts +17 -1
- package/dist/agents/codex.js +144 -1
- package/dist/agents/detect.js +2 -1
- package/dist/agents/detection.d.ts +17 -0
- package/dist/agents/detection.js +89 -0
- package/dist/agents/generic-adapter.js +3 -13
- package/dist/agents/registry-data.d.ts +126 -0
- package/dist/agents/registry-data.js +436 -0
- package/dist/agents/registry.d.ts +8 -48
- package/dist/agents/registry.js +9 -192
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/package.json +1 -1
|
@@ -210,9 +210,180 @@ describe('ClaudeCodeAdapter.readVersion', () => {
|
|
|
210
210
|
});
|
|
211
211
|
});
|
|
212
212
|
describe('ClaudeCodeAdapter.readSkillUsage', () => {
|
|
213
|
-
it('returns null when
|
|
214
|
-
|
|
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
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
441
|
-
|
|
488
|
+
}
|
|
489
|
+
for (const file of files) {
|
|
490
|
+
if (!file.endsWith('.jsonl'))
|
|
442
491
|
continue;
|
|
443
|
-
const
|
|
492
|
+
const filePath = join(cwdPath, file);
|
|
493
|
+
let fileStat;
|
|
444
494
|
try {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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
|
*/
|
package/dist/agents/codex.d.ts
CHANGED
|
@@ -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,6 +14,9 @@ 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;
|
|
17
20
|
/**
|
|
18
21
|
* Register a workspace directory in the Codex desktop app's project list.
|
|
19
22
|
* Adds the path to electron-saved-workspace-roots, project-order, and
|
|
@@ -26,3 +29,16 @@ export declare class CodexAdapter implements AgentAdapter {
|
|
|
26
29
|
*/
|
|
27
30
|
registerDesktopWorkspace(workspacePath: string, label: string): 'written' | 'already_registered' | 'app_running';
|
|
28
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>;
|
|
44
|
+
}
|