@phnx-labs/agents-cli 1.20.57 → 1.20.59

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 (60) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +40 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/defaults.js +24 -0
  5. package/dist/commands/exec.js +29 -5
  6. package/dist/commands/output.d.ts +19 -0
  7. package/dist/commands/output.js +333 -0
  8. package/dist/commands/secrets.js +34 -25
  9. package/dist/commands/versions.js +11 -3
  10. package/dist/commands/view.js +19 -4
  11. package/dist/index.js +2 -1
  12. package/dist/lib/agents.d.ts +21 -0
  13. package/dist/lib/agents.js +42 -14
  14. package/dist/lib/daemon.d.ts +5 -5
  15. package/dist/lib/daemon.js +65 -17
  16. package/dist/lib/git.d.ts +9 -0
  17. package/dist/lib/git.js +12 -0
  18. package/dist/lib/hosts/dispatch.d.ts +21 -0
  19. package/dist/lib/hosts/dispatch.js +88 -5
  20. package/dist/lib/hosts/passthrough.js +1 -0
  21. package/dist/lib/mcp.js +1 -1
  22. package/dist/lib/output/git-output.d.ts +74 -0
  23. package/dist/lib/output/git-output.js +213 -0
  24. package/dist/lib/permissions.d.ts +31 -1
  25. package/dist/lib/permissions.js +210 -9
  26. package/dist/lib/project-root.d.ts +65 -0
  27. package/dist/lib/project-root.js +134 -0
  28. package/dist/lib/resources/mcp.js +1 -1
  29. package/dist/lib/resources/permissions.js +5 -1
  30. package/dist/lib/resources/skills.js +6 -1
  31. package/dist/lib/resources/types.d.ts +1 -1
  32. package/dist/lib/secrets/agent.d.ts +26 -23
  33. package/dist/lib/secrets/agent.js +196 -216
  34. package/dist/lib/secrets/remote.d.ts +7 -2
  35. package/dist/lib/secrets/remote.js +12 -10
  36. package/dist/lib/session/active.d.ts +3 -0
  37. package/dist/lib/session/active.js +1 -0
  38. package/dist/lib/session/db.d.ts +3 -0
  39. package/dist/lib/session/db.js +20 -4
  40. package/dist/lib/session/discover.d.ts +2 -0
  41. package/dist/lib/session/discover.js +40 -4
  42. package/dist/lib/session/parse.js +38 -15
  43. package/dist/lib/session/state.d.ts +4 -1
  44. package/dist/lib/session/state.js +18 -1
  45. package/dist/lib/session/types.d.ts +10 -0
  46. package/dist/lib/staleness/detectors/permissions.js +64 -1
  47. package/dist/lib/staleness/detectors/subagents.js +30 -0
  48. package/dist/lib/staleness/writers/commands.js +3 -3
  49. package/dist/lib/staleness/writers/subagents.js +13 -1
  50. package/dist/lib/startup/command-registry.d.ts +1 -0
  51. package/dist/lib/startup/command-registry.js +2 -0
  52. package/dist/lib/subagents.d.ts +23 -0
  53. package/dist/lib/subagents.js +161 -12
  54. package/dist/lib/teams/agents.d.ts +14 -0
  55. package/dist/lib/teams/agents.js +158 -22
  56. package/dist/lib/types.d.ts +13 -0
  57. package/dist/lib/versions.d.ts +39 -0
  58. package/dist/lib/versions.js +199 -12
  59. package/package.json +1 -1
  60. package/scripts/postinstall.js +26 -11
@@ -24,6 +24,7 @@ export interface SessionRow {
24
24
  label: string | null;
25
25
  message_count: number | null;
26
26
  token_count: number | null;
27
+ output_tokens: number | null;
27
28
  cost_usd: number | null;
28
29
  duration_ms: number | null;
29
30
  file_path: string;
@@ -184,6 +185,8 @@ export interface UsageRollupRow {
184
185
  durationMs: number;
185
186
  sessionCount: number;
186
187
  tokenCount: number;
188
+ /** Real generated (output) tokens — excludes cache-read/-write context. */
189
+ outputTokens: number;
187
190
  }
188
191
  /** What to group a usage rollup by. */
189
192
  export type UsageRollupGroup = 'agent' | 'project' | 'day';
@@ -13,7 +13,7 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
13
13
  const SESSIONS_DIR = getSessionsDir();
14
14
  const DB_PATH = getSessionsDbPath();
15
15
  /** Current schema version; bumped when migrations are added. */
16
- const SCHEMA_VERSION = 11;
16
+ const SCHEMA_VERSION = 12;
17
17
  /**
18
18
  * Canonicalize a file path for use as a scan_ledger key. The same physical
19
19
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -54,6 +54,7 @@ CREATE TABLE IF NOT EXISTS sessions (
54
54
  label TEXT,
55
55
  message_count INTEGER,
56
56
  token_count INTEGER,
57
+ output_tokens INTEGER,
57
58
  cost_usd REAL,
58
59
  duration_ms INTEGER,
59
60
  file_path TEXT NOT NULL,
@@ -224,6 +225,16 @@ function migrateSchema(db, fromVersion) {
224
225
  db.exec(`ALTER TABLE sessions ADD COLUMN plan TEXT`);
225
226
  db.exec(`DELETE FROM scan_ledger;`);
226
227
  }
228
+ if (fromVersion < 12) {
229
+ // v11 → v12: `output_tokens` — the real generated-token count, kept separate
230
+ // from `token_count` (which sums cache-read/-write and so is dominated by
231
+ // cheap re-counted context). This is the honest "output" metric powering
232
+ // `agents output`. Additive column; rescan to backfill from transcripts.
233
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
234
+ if (!cols.some(c => c.name === 'output_tokens'))
235
+ db.exec(`ALTER TABLE sessions ADD COLUMN output_tokens INTEGER`);
236
+ db.exec(`DELETE FROM scan_ledger;`);
237
+ }
227
238
  }
228
239
  /** Open (or return the cached) sessions database, applying migrations as needed. */
229
240
  export function getDB() {
@@ -438,13 +449,13 @@ const upsertSessionStmt = (db) => db.prepare(`
438
449
  INSERT INTO sessions (
439
450
  id, short_id, agent, version, account, timestamp, last_activity,
440
451
  project, cwd, git_branch, topic, label, message_count, token_count,
441
- cost_usd, duration_ms,
452
+ output_tokens, cost_usd, duration_ms,
442
453
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
443
454
  pr_url, pr_number, worktree_slug, ticket_id, plan
444
455
  ) VALUES (
445
456
  @id, @short_id, @agent, @version, @account, @timestamp, @last_activity,
446
457
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
447
- @cost_usd, @duration_ms,
458
+ @output_tokens, @cost_usd, @duration_ms,
448
459
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
449
460
  @pr_url, @pr_number, @worktree_slug, @ticket_id, @plan
450
461
  )
@@ -462,6 +473,7 @@ const upsertSessionStmt = (db) => db.prepare(`
462
473
  label = excluded.label,
463
474
  message_count = excluded.message_count,
464
475
  token_count = excluded.token_count,
476
+ output_tokens = excluded.output_tokens,
465
477
  cost_usd = excluded.cost_usd,
466
478
  duration_ms = excluded.duration_ms,
467
479
  file_path = excluded.file_path,
@@ -510,6 +522,7 @@ export function upsertSession(meta, content, scan) {
510
522
  label: meta.label ?? null,
511
523
  message_count: meta.messageCount ?? null,
512
524
  token_count: meta.tokenCount ?? null,
525
+ output_tokens: meta.outputTokens ?? null,
513
526
  cost_usd: meta.costUsd ?? null,
514
527
  duration_ms: meta.durationMs ?? null,
515
528
  file_path: meta.filePath,
@@ -599,6 +612,7 @@ export function upsertSessionsBatch(entries) {
599
612
  label: meta.label ?? null,
600
613
  message_count: meta.messageCount ?? null,
601
614
  token_count: meta.tokenCount ?? null,
615
+ output_tokens: meta.outputTokens ?? null,
602
616
  cost_usd: meta.costUsd ?? null,
603
617
  duration_ms: meta.durationMs ?? null,
604
618
  file_path: meta.filePath,
@@ -772,6 +786,7 @@ function rowToMeta(row) {
772
786
  gitBranch: row.git_branch ?? undefined,
773
787
  messageCount: row.message_count ?? undefined,
774
788
  tokenCount: row.token_count ?? undefined,
789
+ outputTokens: row.output_tokens ?? undefined,
775
790
  costUsd: row.cost_usd ?? undefined,
776
791
  durationMs: row.duration_ms ?? undefined,
777
792
  version: row.version ?? undefined,
@@ -956,7 +971,8 @@ export function queryUsageRollup(options) {
956
971
  IFNULL(SUM(cost_usd), 0) AS costUsd,
957
972
  IFNULL(SUM(duration_ms), 0) AS durationMs,
958
973
  COUNT(*) AS sessionCount,
959
- IFNULL(SUM(token_count), 0) AS tokenCount
974
+ IFNULL(SUM(token_count), 0) AS tokenCount,
975
+ IFNULL(SUM(output_tokens), 0) AS outputTokens
960
976
  FROM sessions
961
977
  ${clause}
962
978
  GROUP BY key
@@ -46,6 +46,8 @@ interface ClaudeSessionScan {
46
46
  topic?: string;
47
47
  messageCount: number;
48
48
  tokenCount?: number;
49
+ /** Real generated (output) tokens, excluding cache-read/-write context. */
50
+ outputTokens?: number;
49
51
  /** Total USD cost accumulated from per-(model, direction) token usage. */
50
52
  costUsd?: number;
51
53
  /** Wall-clock duration in ms between the first and last timestamped event. */
@@ -508,6 +508,7 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
508
508
  label,
509
509
  messageCount: scan.messageCount,
510
510
  tokenCount: scan.tokenCount,
511
+ outputTokens: scan.outputTokens,
511
512
  costUsd: scan.costUsd,
512
513
  durationMs: scan.durationMs,
513
514
  isTeamOrigin,
@@ -533,6 +534,7 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
533
534
  label,
534
535
  messageCount: scan.messageCount,
535
536
  tokenCount: scan.tokenCount,
537
+ outputTokens: scan.outputTokens,
536
538
  costUsd: scan.costUsd,
537
539
  durationMs: scan.durationMs,
538
540
  topic: scan.topic,
@@ -748,6 +750,7 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion) {
748
750
  topic: scan.topic,
749
751
  messageCount: scan.messageCount,
750
752
  tokenCount: scan.tokenCount,
753
+ outputTokens: scan.outputTokens,
751
754
  costUsd: scan.costUsd,
752
755
  durationMs: scan.durationMs,
753
756
  account: resolveAccount?.(),
@@ -873,6 +876,7 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
873
876
  let topic;
874
877
  let messageCount = 0;
875
878
  let tokenCount = 0;
879
+ let outputTokens = 0;
876
880
  let sawTokenCount = false;
877
881
  let costUsd = 0;
878
882
  let sawCost = false;
@@ -910,6 +914,16 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
910
914
  tokenCount += total;
911
915
  sawTokenCount = true;
912
916
  }
917
+ // Output tokens: sum directional generation fields per message (output +
918
+ // thoughts + tool), mirroring the cost path — never `tokens.total`, which
919
+ // may be cumulative and would double-count when summed.
920
+ const gtk = message.tokens;
921
+ if (gtk && typeof gtk === 'object') {
922
+ outputTokens +=
923
+ (typeof gtk.output === 'number' ? gtk.output : 0) +
924
+ (typeof gtk.thoughts === 'number' ? gtk.thoughts : 0) +
925
+ (typeof gtk.tool === 'number' ? gtk.tool : 0);
926
+ }
913
927
  // Per-message cost: directional tokens × this message's model price.
914
928
  const msgModel = (typeof message.model === 'string' ? message.model : undefined) || sessionModel;
915
929
  const tk = message.tokens;
@@ -944,6 +958,7 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
944
958
  topic,
945
959
  messageCount,
946
960
  tokenCount: sawTokenCount ? tokenCount : undefined,
961
+ outputTokens: sawTokenCount ? outputTokens : undefined,
947
962
  costUsd: sawCost ? costUsd : undefined,
948
963
  durationMs,
949
964
  };
@@ -1158,6 +1173,7 @@ async function scanOpenCodeIncremental() {
1158
1173
  s.time_updated AS time_updated,
1159
1174
  COALESCE(stats.message_count, 0) AS message_count,
1160
1175
  stats.token_count AS token_count,
1176
+ stats.output_tokens AS output_tokens,
1161
1177
  COALESCE(stats.has_token_data, 0) AS has_token_data
1162
1178
  FROM session s
1163
1179
  LEFT JOIN (
@@ -1171,6 +1187,7 @@ async function scanOpenCodeIncremental() {
1171
1187
  COALESCE(json_extract(data, '$.tokens.cache.read'), 0) +
1172
1188
  COALESCE(json_extract(data, '$.tokens.cache.write'), 0)
1173
1189
  ) AS token_count,
1190
+ SUM(COALESCE(json_extract(data, '$.tokens.output'), 0)) AS output_tokens,
1174
1191
  MAX(CASE WHEN json_type(data, '$.tokens') IS NOT NULL THEN 1 ELSE 0 END) AS has_token_data
1175
1192
  FROM message
1176
1193
  GROUP BY session_id
@@ -1194,6 +1211,7 @@ async function scanOpenCodeIncremental() {
1194
1211
  const timeUpdated = asInt(row.time_updated);
1195
1212
  const messageCount = asInt(row.message_count);
1196
1213
  const tokenCount = asInt(row.token_count);
1214
+ const outputTokens = asInt(row.output_tokens);
1197
1215
  const hasTokenData = asInt(row.has_token_data) === 1;
1198
1216
  const timestamp = isNaN(timeCreated) ? new Date().toISOString() : new Date(timeCreated).toISOString();
1199
1217
  // OpenCode is one shared DB, not one file per session — its row carries a
@@ -1215,6 +1233,7 @@ async function scanOpenCodeIncremental() {
1215
1233
  topic,
1216
1234
  messageCount: Number.isNaN(messageCount) ? undefined : messageCount,
1217
1235
  tokenCount: hasTokenData && !Number.isNaN(tokenCount) ? tokenCount : undefined,
1236
+ outputTokens: hasTokenData && !Number.isNaN(outputTokens) ? outputTokens : undefined,
1218
1237
  };
1219
1238
  entries.push({ meta, content: topic || '', scan: currentScan });
1220
1239
  }
@@ -1653,6 +1672,7 @@ async function readDroidMeta(filePath, currentVersion) {
1653
1672
  topic: scan.topic,
1654
1673
  messageCount: scan.messageCount,
1655
1674
  tokenCount,
1675
+ outputTokens: settings.usage?.outputTokens,
1656
1676
  costUsd: costUsd > 0 ? costUsd : undefined,
1657
1677
  durationMs: scan.durationMs,
1658
1678
  };
@@ -1801,6 +1821,7 @@ export async function scanClaudeSession(filePath) {
1801
1821
  let entrypoint;
1802
1822
  let messageCount = 0;
1803
1823
  let tokenCount = 0;
1824
+ let outputTokens = 0;
1804
1825
  let sawTokenCount = false;
1805
1826
  let costUsd = 0;
1806
1827
  let sawCost = false;
@@ -1961,6 +1982,8 @@ export async function scanClaudeSession(filePath) {
1961
1982
  tokenCount += usage;
1962
1983
  sawTokenCount = true;
1963
1984
  }
1985
+ if (typeof usageObj?.output_tokens === 'number')
1986
+ outputTokens += usageObj.output_tokens;
1964
1987
  // Per-assistant-message cost: each event carries its own model, so we
1965
1988
  // multiply that event's raw token directions by that model's price.
1966
1989
  const model = parsed.message?.model;
@@ -2000,6 +2023,7 @@ export async function scanClaudeSession(filePath) {
2000
2023
  entrypoint,
2001
2024
  messageCount,
2002
2025
  tokenCount: sawTokenCount ? tokenCount : undefined,
2026
+ outputTokens: sawTokenCount ? outputTokens : undefined,
2003
2027
  costUsd: sawCost ? costUsd : undefined,
2004
2028
  durationMs,
2005
2029
  lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
@@ -2167,6 +2191,9 @@ async function scanCodexSession(filePath) {
2167
2191
  topic,
2168
2192
  messageCount,
2169
2193
  tokenCount,
2194
+ outputTokens: lastTotalTokenUsage
2195
+ ? (lastTotalTokenUsage.output_tokens ?? 0) + (lastTotalTokenUsage.reasoning_output_tokens ?? 0)
2196
+ : undefined,
2170
2197
  costUsd,
2171
2198
  durationMs,
2172
2199
  lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
@@ -2500,7 +2527,7 @@ export function readKimiMeta(filePath) {
2500
2527
  }
2501
2528
  }
2502
2529
  // Parse wire.jsonl to extract message count and token usage
2503
- const { messageCount, tokenCount } = parseKimiWireMetrics(sessionDir);
2530
+ const { messageCount, tokenCount, outputTokens } = parseKimiWireMetrics(sessionDir);
2504
2531
  const meta = {
2505
2532
  id: sessionId,
2506
2533
  shortId,
@@ -2511,6 +2538,7 @@ export function readKimiMeta(filePath) {
2511
2538
  topic,
2512
2539
  messageCount,
2513
2540
  tokenCount: tokenCount > 0 ? tokenCount : undefined,
2541
+ outputTokens: outputTokens > 0 ? outputTokens : undefined,
2514
2542
  };
2515
2543
  return { meta, content: lastPrompt || '' };
2516
2544
  }
@@ -2522,8 +2550,9 @@ function parseKimiWireMetrics(sessionDir) {
2522
2550
  const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
2523
2551
  let messageCount = 0;
2524
2552
  let tokenCount = 0;
2553
+ let outputTokens = 0;
2525
2554
  if (!fs.existsSync(wirePath)) {
2526
- return { messageCount: 0, tokenCount: 0 };
2555
+ return { messageCount: 0, tokenCount: 0, outputTokens: 0 };
2527
2556
  }
2528
2557
  try {
2529
2558
  const lines = fs.readFileSync(wirePath, 'utf-8').split('\n');
@@ -2539,6 +2568,7 @@ function parseKimiWireMetrics(sessionDir) {
2539
2568
  // Kimi usage structure: inputOther + output + inputCacheRead + inputCacheCreation
2540
2569
  const u = event.usage;
2541
2570
  tokenCount += (u.inputOther || 0) + (u.output || 0) + (u.inputCacheRead || 0) + (u.inputCacheCreation || 0);
2571
+ outputTokens += (u.output || 0);
2542
2572
  }
2543
2573
  }
2544
2574
  catch {
@@ -2549,11 +2579,13 @@ function parseKimiWireMetrics(sessionDir) {
2549
2579
  catch {
2550
2580
  // If wire.jsonl can't be read, return 0s (graceful degradation)
2551
2581
  }
2552
- return { messageCount, tokenCount };
2582
+ return { messageCount, tokenCount, outputTokens };
2553
2583
  }
2554
2584
  /** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
2555
2585
  export function parseTimeFilter(input) {
2556
- const relativeMatch = input.match(/^(\d+)([mhdw])$/i);
2586
+ // Units: m=minute, h=hour, d=day, w=week, mo=month(30d), y=year(365d). `mo`
2587
+ // must precede the single-letter alternatives so "1mo" isn't read as "1m"+"o".
2588
+ const relativeMatch = input.match(/^(\d+)(mo|[mhdwy])$/i);
2557
2589
  if (relativeMatch) {
2558
2590
  const value = parseInt(relativeMatch[1], 10);
2559
2591
  const unit = relativeMatch[2].toLowerCase();
@@ -2565,6 +2597,10 @@ export function parseTimeFilter(input) {
2565
2597
  return Date.now() - value * 86_400_000;
2566
2598
  if (unit === 'w')
2567
2599
  return Date.now() - value * 7 * 86_400_000;
2600
+ if (unit === 'mo')
2601
+ return Date.now() - value * 30 * 86_400_000;
2602
+ if (unit === 'y')
2603
+ return Date.now() - value * 365 * 86_400_000;
2568
2604
  }
2569
2605
  const ts = new Date(input).getTime();
2570
2606
  return Number.isNaN(ts) ? 0 : ts;
@@ -55,6 +55,8 @@ function sanitizeEvent(e) {
55
55
  e.command = sanitizeForTerminal(e.command);
56
56
  if (e.path)
57
57
  e.path = sanitizeForTerminal(e.path);
58
+ if (e.name)
59
+ e.name = sanitizeForTerminal(e.name);
58
60
  if (e.output)
59
61
  e.output = sanitizeForTerminal(e.output);
60
62
  if (e.tool)
@@ -66,6 +68,36 @@ function sanitizeEvent(e) {
66
68
  if (e.args)
67
69
  e.args = sanitizeArgsDeep(e.args);
68
70
  }
71
+ function firstString(...values) {
72
+ for (const value of values) {
73
+ if (typeof value === 'string' && value.trim())
74
+ return value.trim();
75
+ }
76
+ return undefined;
77
+ }
78
+ function attachmentPath(block, source) {
79
+ return firstString(source?.path, source?.file_path, source?.filePath, source?.url, source?.ref, block?.path, block?.file_path, block?.filePath, block?.ref);
80
+ }
81
+ function attachmentName(block, source, filePath) {
82
+ return firstString(block?.name, block?.title, source?.name, source?.filename, source?.file_name, source?.fileName, filePath ? path.basename(filePath) : undefined);
83
+ }
84
+ function normalizedAttachmentEvent(agent, timestamp, block, source, defaultMediaType, sizeBytes) {
85
+ const filePath = attachmentPath(block, source);
86
+ const name = attachmentName(block, source, filePath);
87
+ const explicitSize = typeof source?.sizeBytes === 'number' ? source.sizeBytes :
88
+ typeof source?.size === 'number' ? source.size :
89
+ typeof block?.sizeBytes === 'number' ? block.sizeBytes :
90
+ undefined;
91
+ return {
92
+ type: 'attachment',
93
+ agent,
94
+ timestamp,
95
+ path: filePath,
96
+ name,
97
+ mediaType: firstString(source?.media_type, source?.mediaType, block?.media_type, block?.mediaType) || defaultMediaType,
98
+ sizeBytes: sizeBytes || explicitSize || 0,
99
+ };
100
+ }
69
101
  /**
70
102
  * Read a session file, refusing files above maxBytes. Bounded read protects
71
103
  * against multi-GB session blobs that would OOM the CLI or exceed V8's
@@ -351,24 +383,15 @@ export function parseClaudeContent(content) {
351
383
  const source = block.source || {};
352
384
  if (source.type === 'base64') {
353
385
  const sizeBytes = Math.ceil((source.data?.length || 0) * 0.75);
354
- events.push({
355
- type: 'attachment',
356
- agent: 'claude',
357
- timestamp,
358
- mediaType: source.media_type || 'image/png',
359
- sizeBytes,
360
- });
386
+ events.push(normalizedAttachmentEvent('claude', timestamp, block, source, 'image/png', sizeBytes));
387
+ }
388
+ else {
389
+ events.push(normalizedAttachmentEvent('claude', timestamp, block, source, 'image/png', 0));
361
390
  }
362
391
  }
363
392
  else if (block.type === 'document') {
364
393
  const source = block.source || {};
365
- events.push({
366
- type: 'attachment',
367
- agent: 'claude',
368
- timestamp,
369
- mediaType: source.media_type || 'application/pdf',
370
- sizeBytes: 0,
371
- });
394
+ events.push(normalizedAttachmentEvent('claude', timestamp, block, source, 'application/pdf', 0));
372
395
  }
373
396
  else if (block.type === 'tool_result') {
374
397
  const toolId = block.tool_use_id;
@@ -1554,7 +1577,7 @@ export function parseDroid(filePath) {
1554
1577
  else if (block.type === 'image') {
1555
1578
  const source = block.source || {};
1556
1579
  const sizeBytes = source.type === 'base64' ? Math.ceil((source.data?.length || 0) * 0.75) : 0;
1557
- events.push({ type: 'attachment', agent: 'droid', timestamp, mediaType: source.media_type || 'image/png', sizeBytes });
1580
+ events.push(normalizedAttachmentEvent('droid', timestamp, block, source, 'image/png', sizeBytes));
1558
1581
  }
1559
1582
  }
1560
1583
  }
@@ -14,7 +14,7 @@
14
14
  * markers. Codex has no such tools, so it falls back to last-role + question
15
15
  * shape + mtime — same function, driven off the normalized events.
16
16
  */
17
- import type { SessionEvent } from './types.js';
17
+ import type { SessionAttachment, SessionEvent } from './types.js';
18
18
  export type SessionActivity = 'working' | 'waiting_input' | 'idle';
19
19
  export type AwaitingReason = 'question' | 'plan_review' | 'permission';
20
20
  /** One discrete choice the agent offered the user. */
@@ -98,6 +98,8 @@ export interface SessionState {
98
98
  createdTickets?: string[];
99
99
  /** Team name this session SPAWNED via `agents teams create/add`. */
100
100
  spawnedTeam?: string;
101
+ /** Displayable files/screenshots attached to the session prompt. */
102
+ attachments?: SessionAttachment[];
101
103
  }
102
104
  export interface StateContext {
103
105
  /** Session file mtime; drives running-vs-stale. */
@@ -169,6 +171,7 @@ export declare function detectDurableSignals(events: SessionEvent[]): {
169
171
  ticket?: DetectedTicket;
170
172
  createdTickets?: string[];
171
173
  spawnedTeam?: string;
174
+ attachments?: SessionAttachment[];
172
175
  };
173
176
  /** Full inference: activity + preview + durable signals + worktree/ticket from ctx. */
174
177
  export declare function inferSessionState(events: SessionEvent[], ctx?: StateContext): SessionState;
@@ -352,6 +352,8 @@ export function detectDurableSignals(events) {
352
352
  let sawTicketCreate = false;
353
353
  let spawnedTeam;
354
354
  const createdTickets = new Set();
355
+ const attachments = [];
356
+ const seenAttachments = new Set();
355
357
  for (const e of events) {
356
358
  // Structural PR signal: a real `gh pr create` tool call, then the pull URL
357
359
  // from a following tool_result — never a bare URL mentioned in prose.
@@ -384,18 +386,32 @@ export function detectDurableSignals(events) {
384
386
  if (!ticket && e.type === 'message' && e.role === 'user') {
385
387
  ticket = detectTicket(e.content);
386
388
  }
389
+ if (e.type === 'attachment') {
390
+ const mediaType = e.mediaType || 'application/octet-stream';
391
+ const key = e.path || e.name || `${mediaType}:${e.sizeBytes ?? 0}:${e.timestamp}`;
392
+ if (key && !seenAttachments.has(key)) {
393
+ seenAttachments.add(key);
394
+ attachments.push({
395
+ path: e.path,
396
+ name: e.name,
397
+ mediaType,
398
+ sizeBytes: e.sizeBytes,
399
+ });
400
+ }
401
+ }
387
402
  }
388
403
  return {
389
404
  pr,
390
405
  ticket,
391
406
  createdTickets: createdTickets.size > 0 ? [...createdTickets] : undefined,
392
407
  spawnedTeam,
408
+ attachments: attachments.length > 0 ? attachments : undefined,
393
409
  };
394
410
  }
395
411
  /** Full inference: activity + preview + durable signals + worktree/ticket from ctx. */
396
412
  export function inferSessionState(events, ctx = {}) {
397
413
  const state = inferActivity(events, ctx);
398
- const { pr, ticket, createdTickets, spawnedTeam } = detectDurableSignals(events);
414
+ const { pr, ticket, createdTickets, spawnedTeam, attachments } = detectDurableSignals(events);
399
415
  const worktree = detectWorktree(ctx.cwd, ctx.gitBranch);
400
416
  // Rate-limit: scan the most recent assistant messages + tool errors (tail-first).
401
417
  let rateLimited = false;
@@ -421,6 +437,7 @@ export function inferSessionState(events, ctx = {}) {
421
437
  ticket: ticket ?? detectTicket(undefined, ctx.gitBranch) ?? state.ticket,
422
438
  createdTickets,
423
439
  spawnedTeam,
440
+ attachments,
424
441
  rateLimited: rateLimited || undefined,
425
442
  };
426
443
  }
@@ -30,9 +30,17 @@ export interface SessionEvent {
30
30
  outputTokens?: number;
31
31
  cacheReadTokens?: number;
32
32
  cacheCreationTokens?: number;
33
+ name?: string;
33
34
  mediaType?: string;
34
35
  sizeBytes?: number;
35
36
  }
37
+ /** A displayable file attachment discovered in a session transcript. */
38
+ export interface SessionAttachment {
39
+ path?: string;
40
+ name?: string;
41
+ mediaType: string;
42
+ sizeBytes?: number;
43
+ }
36
44
  /** Metadata attached when a session was spawned by `agents teams`. */
37
45
  export interface TeamOrigin {
38
46
  /** Teammate name if set, otherwise first 8 chars of the agent UUID. */
@@ -58,6 +66,8 @@ export interface SessionMeta {
58
66
  gitBranch?: string;
59
67
  messageCount?: number;
60
68
  tokenCount?: number;
69
+ /** Real generated (output) tokens — excludes cache-read/-write context (issue: `agents output`). */
70
+ outputTokens?: number;
61
71
  /** Total USD cost, computed at scan time from per-model token usage (issue #323). */
62
72
  costUsd?: number;
63
73
  /** Wall-clock duration in ms (lastTs − firstTs), persisted at scan time. */
@@ -13,6 +13,7 @@
13
13
  import * as fs from 'fs';
14
14
  import * as path from 'path';
15
15
  import * as TOML from 'smol-toml';
16
+ import * as yaml from 'yaml';
16
17
  import { capableAgents } from '../../capabilities.js';
17
18
  import { discoverPermissionGroups, buildPermissionsFromGroups, CODEX_RULES_FILENAME, } from '../../permissions.js';
18
19
  import { lazyAgentMap } from '../writers/lazy-map.js';
@@ -83,7 +84,7 @@ function buildOpenCodeDetector() {
83
84
  kind: 'permissions',
84
85
  agent: 'opencode',
85
86
  list({ versionHome }) {
86
- const opencodeConfigPath = path.join(versionHome, '.opencode', 'opencode.jsonc');
87
+ const opencodeConfigPath = path.join(versionHome, '.config', 'opencode', 'opencode.jsonc');
87
88
  if (!fs.existsSync(opencodeConfigPath))
88
89
  return [];
89
90
  try {
@@ -181,6 +182,65 @@ function buildKimiDetector() {
181
182
  },
182
183
  };
183
184
  }
185
+ function buildCursorDetector() {
186
+ return {
187
+ kind: 'permissions',
188
+ agent: 'cursor',
189
+ list({ versionHome }) {
190
+ const configPath = path.join(versionHome, '.cursor', 'cli-config.json');
191
+ if (!fs.existsSync(configPath))
192
+ return [];
193
+ try {
194
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
195
+ const allow = config.permissions?.allow?.length ?? 0;
196
+ const deny = config.permissions?.deny?.length ?? 0;
197
+ if (allow + deny > 0)
198
+ return discoverPermissionGroups().map(g => g.name);
199
+ }
200
+ catch { /* parse fail */ }
201
+ return [];
202
+ },
203
+ };
204
+ }
205
+ function buildDroidDetector() {
206
+ return {
207
+ kind: 'permissions',
208
+ agent: 'droid',
209
+ list({ versionHome }) {
210
+ const settingsPath = path.join(versionHome, '.factory', 'settings.json');
211
+ if (!fs.existsSync(settingsPath))
212
+ return [];
213
+ try {
214
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
215
+ const hasAllow = Array.isArray(settings.commandAllowlist) && settings.commandAllowlist.length > 0;
216
+ const hasDeny = Array.isArray(settings.commandDenylist) && settings.commandDenylist.length > 0;
217
+ if (hasAllow || hasDeny)
218
+ return discoverPermissionGroups().map(g => g.name);
219
+ }
220
+ catch { /* parse fail */ }
221
+ return [];
222
+ },
223
+ };
224
+ }
225
+ function buildKiroDetector() {
226
+ return {
227
+ kind: 'permissions',
228
+ agent: 'kiro',
229
+ list({ versionHome }) {
230
+ const permissionsPath = path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
231
+ if (!fs.existsSync(permissionsPath))
232
+ return [];
233
+ try {
234
+ const config = yaml.parse(fs.readFileSync(permissionsPath, 'utf-8'));
235
+ if (config && Array.isArray(config.rules) && config.rules.length > 0) {
236
+ return discoverPermissionGroups().map(g => g.name);
237
+ }
238
+ }
239
+ catch { /* parse fail */ }
240
+ return [];
241
+ },
242
+ };
243
+ }
184
244
  const handlers = {
185
245
  claude: buildClaudeDetector,
186
246
  codex: buildCodexDetector,
@@ -189,6 +249,9 @@ const handlers = {
189
249
  antigravity: buildAntigravityDetector,
190
250
  grok: buildGrokDetector,
191
251
  kimi: buildKimiDetector,
252
+ cursor: buildCursorDetector,
253
+ droid: buildDroidDetector,
254
+ kiro: buildKiroDetector,
192
255
  };
193
256
  export const permissionsDetectors = lazyAgentMap(() => {
194
257
  const m = {};
@@ -57,6 +57,20 @@ function buildDroidDetector() {
57
57
  },
58
58
  };
59
59
  }
60
+ function buildCopilotDetector() {
61
+ return {
62
+ kind: 'subagents',
63
+ agent: 'copilot',
64
+ list({ versionHome }) {
65
+ const agentsDir = path.join(versionHome, '.copilot', 'agents');
66
+ if (!fs.existsSync(agentsDir))
67
+ return [];
68
+ return fs.readdirSync(agentsDir)
69
+ .filter(f => f.endsWith('.agent.md'))
70
+ .map(f => f.replace('.agent.md', ''));
71
+ },
72
+ };
73
+ }
60
74
  function buildOpenclawDetector() {
61
75
  return {
62
76
  kind: 'subagents',
@@ -86,6 +100,20 @@ function buildKimiDetector() {
86
100
  },
87
101
  };
88
102
  }
103
+ function buildKiroDetector() {
104
+ return {
105
+ kind: 'subagents',
106
+ agent: 'kiro',
107
+ list({ versionHome }) {
108
+ const agentsDir = path.join(versionHome, '.kiro', 'agents');
109
+ if (!fs.existsSync(agentsDir))
110
+ return [];
111
+ return fs.readdirSync(agentsDir)
112
+ .filter(f => f.endsWith('.json'))
113
+ .map(f => f.replace(/\.json$/, ''));
114
+ },
115
+ };
116
+ }
89
117
  function buildOpenCodeDetector() {
90
118
  return {
91
119
  kind: 'subagents',
@@ -102,12 +130,14 @@ function buildOpenCodeDetector() {
102
130
  }
103
131
  const handlers = {
104
132
  claude: buildClaudeDetector,
133
+ copilot: buildCopilotDetector,
105
134
  grok: buildGrokDetector,
106
135
  codex: buildCodexDetector,
107
136
  kimi: buildKimiDetector,
108
137
  opencode: buildOpenCodeDetector,
109
138
  droid: buildDroidDetector,
110
139
  openclaw: buildOpenclawDetector,
140
+ kiro: buildKiroDetector,
111
141
  };
112
142
  export const subagentsDetectors = lazyAgentMap(() => {
113
143
  const m = {};
@@ -36,10 +36,10 @@ function buildCommandsWriter(agent) {
36
36
  const agentDir = path.join(versionHome, agentConfigDirName(agent));
37
37
  const commandsAsSkills = shouldInstallCommandAsSkill(agent, version);
38
38
  const supportsCommands = supports(agent, 'commands', version).ok;
39
- // Writers fire only after supports() OR commands-as-skills says yes
40
- // both paths produce a usable result here.
39
+ // Version-gated agents (e.g. goose skills >= 1.25.0) are registered but
40
+ // may be called at a version too old for both paths — skip gracefully.
41
41
  if (!commandsAsSkills && !supportsCommands) {
42
- throw new Error(`commands writer reached for ${agent}@${version} with no path (cmd=false, asSkill=false)`);
42
+ return { synced: [] };
43
43
  }
44
44
  const skillRoots = trustedSkillRoots();
45
45
  const commandsTarget = path.join(agentDir, agentConfig.commandsSubdir);