@phnx-labs/agents-cli 1.20.58 → 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.
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Git / GitHub "output" collector — the shipped-work half of `agents output`.
3
+ *
4
+ * `agents cost` answers "what did we burn?"; this answers "what did we ship?".
5
+ * It counts commits (across every author identity, so multi-account totals stay
6
+ * correct regardless of which `gh` login is active) and PRs opened / merged in a
7
+ * time window. Pure `git`/`gh` over child_process — no server, no telemetry,
8
+ * mirroring the offline spirit of the cost rollup.
9
+ */
10
+ import { execFile } from 'child_process';
11
+ import { promisify } from 'util';
12
+ import * as fs from 'fs';
13
+ import * as os from 'os';
14
+ import * as path from 'path';
15
+ const execFileAsync = promisify(execFile);
16
+ /** Directory names never descended into during repo discovery. */
17
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.agents', '.worktrees', 'dist', 'build', '.next', '.cache']);
18
+ /**
19
+ * Find git repositories under `root` up to `maxDepth` levels deep. A directory
20
+ * with a `.git` entry is a repo and is NOT descended into (so nested worktrees
21
+ * / submodules don't double-count).
22
+ */
23
+ export function findGitRepos(root, maxDepth = 4) {
24
+ const repos = [];
25
+ const walk = (dir, depth) => {
26
+ let entries;
27
+ try {
28
+ entries = fs.readdirSync(dir, { withFileTypes: true });
29
+ }
30
+ catch {
31
+ return; // unreadable dir — skip
32
+ }
33
+ if (entries.some(e => e.name === '.git')) {
34
+ repos.push(dir);
35
+ return; // don't descend into a repo
36
+ }
37
+ if (depth >= maxDepth)
38
+ return;
39
+ for (const e of entries) {
40
+ if (!e.isDirectory() || e.name.startsWith('.') || SKIP_DIRS.has(e.name))
41
+ continue;
42
+ walk(path.join(dir, e.name), depth + 1);
43
+ }
44
+ };
45
+ walk(root, 0);
46
+ return repos;
47
+ }
48
+ /** git log for one repo (SHA + author email per commit), tolerant of empty/broken repos. */
49
+ async function repoLog(repoDir, sinceIso) {
50
+ try {
51
+ const { stdout } = await execFileAsync('git', ['-C', repoDir, 'log', '--all', '--no-merges', `--since=${sinceIso}`, '--pretty=format:%H%x09%ae'], { maxBuffer: 64 * 1024 * 1024 });
52
+ const refs = [];
53
+ for (const line of stdout.split('\n')) {
54
+ const tab = line.indexOf('\t');
55
+ if (tab < 0)
56
+ continue;
57
+ refs.push({ sha: line.slice(0, tab).trim(), email: line.slice(tab + 1).trim() });
58
+ }
59
+ return refs;
60
+ }
61
+ catch {
62
+ return []; // no commits yet / not a real repo / detached weirdness
63
+ }
64
+ }
65
+ /**
66
+ * Discover the user's own author emails so commit counts exclude teammates.
67
+ * Union of `git config --global user.email` and each repo's local `user.email`.
68
+ */
69
+ async function discoverAuthorEmails(repos) {
70
+ const emails = new Set();
71
+ try {
72
+ const { stdout } = await execFileAsync('git', ['config', '--global', 'user.email']);
73
+ const e = stdout.trim();
74
+ if (e)
75
+ emails.add(e.toLowerCase());
76
+ }
77
+ catch {
78
+ /* no global identity */
79
+ }
80
+ await Promise.all(repos.map(async (repo) => {
81
+ try {
82
+ const { stdout } = await execFileAsync('git', ['-C', repo, 'config', '--get', 'user.email']);
83
+ const e = stdout.trim();
84
+ if (e)
85
+ emails.add(e.toLowerCase());
86
+ }
87
+ catch {
88
+ /* repo has no local identity */
89
+ }
90
+ }));
91
+ return [...emails];
92
+ }
93
+ /**
94
+ * Count commits by our authors across all repos, deduped by SHA (so the same
95
+ * commit reachable via multiple repo clones/worktrees on this machine — or, at
96
+ * the fleet layer, across machines — is counted once), tallied per email.
97
+ */
98
+ export async function collectCommits(repos, sinceIso, authors) {
99
+ const ours = new Set(authors.map(a => a.toLowerCase()));
100
+ const tally = new Map();
101
+ const seen = new Set();
102
+ const logs = await Promise.all(repos.map(r => repoLog(r, sinceIso)));
103
+ for (const refs of logs) {
104
+ for (const { sha, email } of refs) {
105
+ const key = email.toLowerCase();
106
+ if (ours.size > 0 && !ours.has(key))
107
+ continue;
108
+ if (seen.has(sha))
109
+ continue; // same commit seen via another clone/ref
110
+ seen.add(sha);
111
+ tally.set(key, (tally.get(key) ?? 0) + 1);
112
+ }
113
+ }
114
+ const byAuthor = [...tally.entries()]
115
+ .map(([author, commits]) => ({ author, commits }))
116
+ .sort((a, b) => b.commits - a.commits);
117
+ return { total: seen.size, byAuthor, shas: [...seen] };
118
+ }
119
+ /** Resolve the current gh login, or null if gh is unavailable/unauthed. */
120
+ async function currentGhLogin() {
121
+ try {
122
+ const { stdout } = await execFileAsync('gh', ['api', 'user', '--jq', '.login']);
123
+ const login = stdout.trim();
124
+ return login || null;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ /** Count PRs matching a gh search query (author + date), returning null on failure. */
131
+ async function ghSearchCount(args) {
132
+ try {
133
+ const { stdout } = await execFileAsync('gh', [...args, '--limit', '1000', '--json', 'number'], {
134
+ maxBuffer: 32 * 1024 * 1024,
135
+ });
136
+ const rows = JSON.parse(stdout);
137
+ return Array.isArray(rows) ? rows.length : 0;
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
143
+ /**
144
+ * Count PRs opened and merged in the window across the given logins. `gh search
145
+ * prs` searches all of GitHub, so one authed gh can cover multiple accounts'
146
+ * logins. Returns ghAvailable=false if gh can't be reached at all.
147
+ */
148
+ export async function collectPrs(logins, sinceDate) {
149
+ let resolved = logins;
150
+ if (resolved.length === 0) {
151
+ const login = await currentGhLogin();
152
+ if (!login)
153
+ return { opened: 0, merged: 0, logins: [], ghAvailable: false };
154
+ resolved = [login];
155
+ }
156
+ let opened = 0;
157
+ let merged = 0;
158
+ let anyOk = false;
159
+ for (const login of resolved) {
160
+ const o = await ghSearchCount(['search', 'prs', '--author', login, '--created', `>=${sinceDate}`]);
161
+ const m = await ghSearchCount(['search', 'prs', '--author', login, '--merged', `>=${sinceDate}`]);
162
+ if (o !== null) {
163
+ opened += o;
164
+ anyOk = true;
165
+ }
166
+ if (m !== null) {
167
+ merged += m;
168
+ anyOk = true;
169
+ }
170
+ }
171
+ return { opened, merged, logins: resolved, ghAvailable: anyOk };
172
+ }
173
+ /** Format an epoch-ms as a YYYY-MM-DD date (UTC), for gh's date-range search. */
174
+ export function toSearchDate(ms) {
175
+ return new Date(ms).toISOString().slice(0, 10);
176
+ }
177
+ /**
178
+ * Collect the full shipped-work summary for a window: commits (across accounts)
179
+ * plus PRs opened/merged.
180
+ */
181
+ export async function collectGitOutput(options) {
182
+ const reposDir = options.reposDir.replace(/^~(?=$|\/)/, os.homedir());
183
+ const sinceIso = new Date(options.sinceMs).toISOString();
184
+ const sinceDate = toSearchDate(options.sinceMs);
185
+ const repos = findGitRepos(reposDir, options.maxDepth ?? 4);
186
+ const authors = options.authors && options.authors.length > 0
187
+ ? options.authors.map(a => a.toLowerCase())
188
+ : await discoverAuthorEmails(repos);
189
+ const { total: commits, byAuthor, shas: commitShas } = await collectCommits(repos, sinceIso, authors);
190
+ let prsOpened = 0;
191
+ let prsMerged = 0;
192
+ let ghAvailable = false;
193
+ let logins = options.logins ?? [];
194
+ if (options.includePrs !== false) {
195
+ const prs = await collectPrs(logins, sinceDate);
196
+ prsOpened = prs.opened;
197
+ prsMerged = prs.merged;
198
+ ghAvailable = prs.ghAvailable;
199
+ logins = prs.logins;
200
+ }
201
+ return {
202
+ reposScanned: repos.length,
203
+ commits,
204
+ byAuthor,
205
+ commitShas,
206
+ prsOpened,
207
+ prsMerged,
208
+ ghAvailable,
209
+ authors,
210
+ logins,
211
+ sinceIso,
212
+ };
213
+ }
@@ -117,6 +117,11 @@ export declare function convertToGeminiFormat(set: PermissionSet): {
117
117
  allowed: string[];
118
118
  };
119
119
  };
120
+ /** Convert canonical Bash rules into Droid's command arrays. */
121
+ export declare function convertToDroidFormat(set: PermissionSet): {
122
+ commandAllowlist: string[];
123
+ commandDenylist: string[];
124
+ };
120
125
  /**
121
126
  * Convert canonical permission set to Antigravity format.
122
127
  * Antigravity reads ~/.gemini/antigravity-cli/settings.json with
@@ -212,6 +217,13 @@ export declare function applyClaudePermissions(set: PermissionSet, scope?: 'user
212
217
  success: boolean;
213
218
  error?: string;
214
219
  };
220
+ /**
221
+ * Path OpenCode actually loads for global config:
222
+ * ~/.config/opencode/opencode.jsonc (or .json)
223
+ * Project: <cwd>/opencode.jsonc (or .json) at project root — not .opencode/.
224
+ * See https://opencode.ai/docs/config/
225
+ */
226
+ export declare function openCodeConfigPath(scope: 'user' | 'project', cwd?: string, home?: string): string;
215
227
  /**
216
228
  * Apply a permission set to a specific version's home directory.
217
229
  * This writes to {versionHome}/.{agent}/settings.json (or equivalent).
@@ -525,6 +525,26 @@ function normalizeBashPattern(pattern) {
525
525
  return pattern.slice(0, -2) + ' *';
526
526
  return pattern;
527
527
  }
528
+ /** Convert canonical Bash rules into Droid's command arrays. */
529
+ export function convertToDroidFormat(set) {
530
+ const commands = (permissions) => {
531
+ const result = new Set();
532
+ for (const permission of permissions) {
533
+ if (BLANKET_BASH_FORMS.has(permission)) {
534
+ result.add('*');
535
+ continue;
536
+ }
537
+ const parsed = parseCanonicalPattern(permission);
538
+ if (parsed?.tool === 'bash')
539
+ result.add(normalizeBashPattern(parsed.pattern));
540
+ }
541
+ return Array.from(result);
542
+ };
543
+ return {
544
+ commandAllowlist: commands(set.allow),
545
+ commandDenylist: commands(set.deny ?? []),
546
+ };
547
+ }
528
548
  /**
529
549
  * Convert canonical permission set to Antigravity format.
530
550
  * Antigravity reads ~/.gemini/antigravity-cli/settings.json with
@@ -919,9 +939,7 @@ function readClaudePermissions(scope = 'user', cwd, options) {
919
939
  */
920
940
  function readOpenCodePermissions(scope = 'user', cwd, options) {
921
941
  const home = options?.home || HOME;
922
- const configPath = scope === 'user'
923
- ? path.join(home, '.opencode', 'opencode.jsonc')
924
- : path.join(cwd || process.cwd(), '.opencode', 'opencode.jsonc');
942
+ const configPath = openCodeConfigPath(scope, cwd, home);
925
943
  if (!fs.existsSync(configPath)) {
926
944
  return null;
927
945
  }
@@ -1031,14 +1049,36 @@ export function applyClaudePermissions(set, scope = 'user', cwd, merge = true) {
1031
1049
  return { success: false, error: err.message };
1032
1050
  }
1033
1051
  }
1052
+ /**
1053
+ * Path OpenCode actually loads for global config:
1054
+ * ~/.config/opencode/opencode.jsonc (or .json)
1055
+ * Project: <cwd>/opencode.jsonc (or .json) at project root — not .opencode/.
1056
+ * See https://opencode.ai/docs/config/
1057
+ */
1058
+ export function openCodeConfigPath(scope, cwd, home = HOME) {
1059
+ if (scope === 'project') {
1060
+ const root = cwd || process.cwd();
1061
+ for (const name of ['opencode.jsonc', 'opencode.json']) {
1062
+ const candidate = path.join(root, name);
1063
+ if (fs.existsSync(candidate))
1064
+ return candidate;
1065
+ }
1066
+ return path.join(root, 'opencode.jsonc');
1067
+ }
1068
+ const globalDir = path.join(home, '.config', 'opencode');
1069
+ for (const name of ['opencode.jsonc', 'opencode.json']) {
1070
+ const candidate = path.join(globalDir, name);
1071
+ if (fs.existsSync(candidate))
1072
+ return candidate;
1073
+ }
1074
+ return path.join(globalDir, 'opencode.jsonc');
1075
+ }
1034
1076
  /**
1035
1077
  * Apply a permission set to OpenCode's opencode.jsonc.
1036
1078
  */
1037
1079
  function applyOpenCodePermissions(set, scope = 'user', cwd, merge = true) {
1038
- const configDir = scope === 'user'
1039
- ? path.join(HOME, '.opencode')
1040
- : path.join(cwd || process.cwd(), '.opencode');
1041
- const configPath = path.join(configDir, 'opencode.jsonc');
1080
+ const configPath = openCodeConfigPath(scope, cwd);
1081
+ const configDir = path.dirname(configPath);
1042
1082
  try {
1043
1083
  // Ensure directory exists
1044
1084
  if (!fs.existsSync(configDir)) {
@@ -1172,7 +1212,10 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
1172
1212
  return { success: true };
1173
1213
  }
1174
1214
  if (agentId === 'opencode') {
1175
- const configPath = path.join(configDir, 'opencode.jsonc');
1215
+ // OpenCode loads ~/.config/opencode/opencode.jsonc under the version home
1216
+ // (HOME isolation), not ~/.opencode/opencode.jsonc.
1217
+ const configPath = openCodeConfigPath('user', undefined, versionHome);
1218
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
1176
1219
  let config = {};
1177
1220
  if (fs.existsSync(configPath)) {
1178
1221
  const content = stripJsonComments(fs.readFileSync(configPath, 'utf-8'));
@@ -1364,6 +1407,27 @@ export function applyPermissionsToVersion(agentId, set, versionHome, merge = tru
1364
1407
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1365
1408
  return { success: true };
1366
1409
  }
1410
+ if (agentId === 'droid') {
1411
+ const configPath = path.join(versionHome, '.factory', 'settings.json');
1412
+ let config = {};
1413
+ if (fs.existsSync(configPath)) {
1414
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1415
+ }
1416
+ const converted = convertToDroidFormat(set);
1417
+ if (merge) {
1418
+ const existingAllow = Array.isArray(config.commandAllowlist) ? config.commandAllowlist : [];
1419
+ const existingDeny = Array.isArray(config.commandDenylist) ? config.commandDenylist : [];
1420
+ config.commandAllowlist = Array.from(new Set([...existingAllow, ...converted.commandAllowlist]));
1421
+ config.commandDenylist = Array.from(new Set([...existingDeny, ...converted.commandDenylist]));
1422
+ }
1423
+ else {
1424
+ config.commandAllowlist = converted.commandAllowlist;
1425
+ config.commandDenylist = converted.commandDenylist;
1426
+ }
1427
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
1428
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1429
+ return { success: true };
1430
+ }
1367
1431
  if (agentId === 'kiro') {
1368
1432
  const permissionsPath = path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
1369
1433
  let config = {};
@@ -1496,7 +1560,7 @@ export function exportPermissionsFromPath(filePath) {
1496
1560
  if (fileName === 'settings.json' && parentDir === '.claude') {
1497
1561
  agentId = 'claude';
1498
1562
  }
1499
- else if (fileName === 'opencode.jsonc' || parentDir === '.opencode') {
1563
+ else if (fileName === 'opencode.jsonc' || fileName === 'opencode.json' || parentDir === 'opencode' || parentDir === '.opencode') {
1500
1564
  agentId = 'opencode';
1501
1565
  }
1502
1566
  else if (fileName === 'config.toml' && parentDir === '.codex') {
@@ -16,6 +16,7 @@ import * as path from 'path';
16
16
  import * as fs from 'fs';
17
17
  import { readMeta, updateMeta } from './state.js';
18
18
  import { getMainRepoRoot } from './git.js';
19
+ import { toPosix } from './platform/index.js';
19
20
  const HOME = process.env.HOME ?? os.homedir();
20
21
  /** Rewrite an absolute path under the local home to a `~/`-relative string; pass others through. */
21
22
  export function toHomeRelative(abs) {
@@ -23,7 +24,7 @@ export function toHomeRelative(abs) {
23
24
  if (rel === '')
24
25
  return '~';
25
26
  if (!rel.startsWith('..') && !path.isAbsolute(rel))
26
- return `~/${rel}`;
27
+ return `~/${toPosix(rel)}`;
27
28
  return abs;
28
29
  }
29
30
  /** Expand a leading `~`/`$HOME` against the LOCAL home. Other paths pass through unchanged. */
@@ -111,7 +111,7 @@ export function getMcpConfigPath(agent, versionHome) {
111
111
  case 'codex':
112
112
  return path.join(versionHome, '.codex', 'config.toml');
113
113
  case 'opencode':
114
- return path.join(versionHome, '.opencode', 'opencode.jsonc');
114
+ return path.join(versionHome, '.config', 'opencode', 'opencode.jsonc');
115
115
  case 'cursor':
116
116
  return path.join(versionHome, '.cursor', 'mcp.json');
117
117
  case 'gemini':
@@ -66,9 +66,11 @@ function getAgentConfigPath(agent, versionHome) {
66
66
  case 'codex':
67
67
  return path.join(versionHome, '.codex', 'config.toml');
68
68
  case 'opencode':
69
- return path.join(versionHome, '.opencode', 'opencode.jsonc');
69
+ return path.join(versionHome, '.config', 'opencode', 'opencode.jsonc');
70
70
  case 'kimi':
71
71
  return path.join(versionHome, '.kimi-code', 'config.toml');
72
+ case 'droid':
73
+ return path.join(versionHome, '.factory', 'settings.json');
72
74
  case 'kiro':
73
75
  return path.join(versionHome, '.kiro', 'settings', 'permissions.yaml');
74
76
  default:
@@ -5,7 +5,7 @@
5
5
  * Format is the same for all agents. Resolution order: project > user > system.
6
6
  */
7
7
  import * as fs from 'fs';
8
- import { agentConfigDirName } from '../agents.js';
8
+ import { AGENTS, agentConfigDirName } from '../agents.js';
9
9
  import * as path from 'path';
10
10
  import * as yaml from 'yaml';
11
11
  import { getSystemSkillsDir, getUserSkillsDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../state.js';
@@ -201,6 +201,11 @@ export function createSkillsHandler(provider = defaultProvider) {
201
201
  return null;
202
202
  },
203
203
  sync(agent, versionHome, cwd) {
204
+ // Agents that read directly from central ~/.agents/skills/ (e.g. Gemini,
205
+ // Goose via the Summon extension) should not get a per-version copy.
206
+ if (AGENTS[agent]?.nativeAgentsSkillsDir) {
207
+ return;
208
+ }
204
209
  const targetDir = path.join(versionHome, agentConfigDirName(agent), 'skills');
205
210
  // Ensure target directory exists
206
211
  if (!fs.existsSync(targetDir)) {
@@ -5,7 +5,7 @@
5
5
  * - Union: All resources from all layers are combined
6
6
  * - Override on name conflict: Higher layer wins (project > user > system)
7
7
  */
8
- export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'kiro' | 'antigravity' | 'grok' | 'kimi' | 'hermes' | 'forge';
8
+ export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'kiro' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'forge';
9
9
  export type Layer = 'system' | 'user' | 'project';
10
10
  export type ResourceKind = 'command' | 'hook' | 'skill' | 'rule' | 'mcp' | 'permission' | 'subagent' | 'workflow' | 'memory';
11
11
  /** A resolved resource with its origin layer. */
@@ -50,6 +50,7 @@ export declare function splitBundleRef(ref: string): {
50
50
  export declare function remoteSecretsRaw(target: string, args: string[], opts?: {
51
51
  tty?: boolean;
52
52
  input?: string;
53
+ osLookupName?: string;
53
54
  }): SshExecResult;
54
55
  /**
55
56
  * Run a remote `agents secrets <args>` FOREGROUND, with the local stdio wired
@@ -64,7 +65,9 @@ export declare function remoteSecretsRaw(target: string, args: string[], opts?:
64
65
  * bundle's passphrase at your own terminal. Output is NOT captured (it streams
65
66
  * to the terminal); only the exit code is returned.
66
67
  */
67
- export declare function remoteSecretsStream(target: string, args: string[]): number;
68
+ export declare function remoteSecretsStream(target: string, args: string[], opts?: {
69
+ osLookupName?: string;
70
+ }): number;
68
71
  /**
69
72
  * Resolve a remote bundle to a plaintext env map by driving the remote's
70
73
  * `agents secrets export <bundle> --plaintext --format json`. Values cross over
@@ -78,4 +81,6 @@ export declare function remoteSecretsStream(target: string, args: string[]): num
78
81
  * SSH will block on Touch-ID — use `view`/`exec` with a remote `file` bundle,
79
82
  * an already-unlocked remote secrets-agent, or an interactive `-tt` session.)
80
83
  */
81
- export declare function remoteResolveEnv(target: string, bundle: string): Promise<Record<string, string>>;
84
+ export declare function remoteResolveEnv(target: string, bundle: string, opts?: {
85
+ osLookupName?: string;
86
+ }): Promise<Record<string, string>>;
@@ -22,11 +22,12 @@ import { sshTargetFor } from '../hosts/types.js';
22
22
  import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
23
23
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
24
24
  const REMOTE_TIMEOUT_MS = 30_000;
25
- /** Remote OS for a target string (bare alias matches a device entry; a raw
26
- * `user@host` falls back to POSIX). Threaded into the command builder so a
27
- * Windows host gets PowerShell instead of `bash -lc`. */
28
- function osForTarget(target) {
29
- return resolveRemoteOsSync(target.split('@').pop() ?? target);
25
+ /** Remote OS for a host name or target string. Prefer the original host name
26
+ * because enrolled inline hosts resolve to `user@address`, while the OS
27
+ * registry is keyed by the host name. */
28
+ function osForTarget(target, lookupName) {
29
+ const byName = lookupName ? resolveRemoteOsSync(lookupName) : undefined;
30
+ return byName ?? resolveRemoteOsSync(target.split('@').pop() ?? target);
30
31
  }
31
32
  /**
32
33
  * Resolve a `--host` value to an ssh target string. Tries the `agents hosts`
@@ -86,7 +87,7 @@ export function splitBundleRef(ref) {
86
87
  * remote Touch-ID / passphrase prompt can surface (e.g. `view --reveal`).
87
88
  */
88
89
  export function remoteSecretsRaw(target, args, opts = {}) {
89
- const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target));
90
+ const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target, opts.osLookupName));
90
91
  return sshExec(target, remoteCmd, {
91
92
  timeoutMs: REMOTE_TIMEOUT_MS,
92
93
  input: opts.input,
@@ -107,8 +108,8 @@ export function remoteSecretsRaw(target, args, opts = {}) {
107
108
  * bundle's passphrase at your own terminal. Output is NOT captured (it streams
108
109
  * to the terminal); only the exit code is returned.
109
110
  */
110
- export function remoteSecretsStream(target, args) {
111
- const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target));
111
+ export function remoteSecretsStream(target, args, opts = {}) {
112
+ const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target, opts.osLookupName));
112
113
  return sshStream(target, remoteCmd, { tty: true });
113
114
  }
114
115
  /**
@@ -124,9 +125,9 @@ export function remoteSecretsStream(target, args) {
124
125
  * SSH will block on Touch-ID — use `view`/`exec` with a remote `file` bundle,
125
126
  * an already-unlocked remote secrets-agent, or an interactive `-tt` session.)
126
127
  */
127
- export async function remoteResolveEnv(target, bundle) {
128
+ export async function remoteResolveEnv(target, bundle, opts = {}) {
128
129
  assertValidSshTarget(target);
129
- const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target));
130
+ const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName));
130
131
  const res = sshExec(target, remoteCmd, {
131
132
  timeoutMs: REMOTE_TIMEOUT_MS,
132
133
  });
@@ -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. */