@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.
- package/CHANGELOG.md +10 -0
- package/README.md +7 -2
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +1 -1
- package/dist/commands/output.d.ts +19 -0
- package/dist/commands/output.js +333 -0
- package/dist/commands/secrets.js +6 -6
- package/dist/index.js +2 -1
- package/dist/lib/agents.js +14 -10
- package/dist/lib/hosts/passthrough.js +1 -0
- package/dist/lib/mcp.js +1 -1
- package/dist/lib/output/git-output.d.ts +74 -0
- package/dist/lib/output/git-output.js +213 -0
- package/dist/lib/permissions.d.ts +12 -0
- package/dist/lib/permissions.js +73 -9
- package/dist/lib/project-root.js +2 -1
- package/dist/lib/resources/mcp.js +1 -1
- package/dist/lib/resources/permissions.js +3 -1
- package/dist/lib/resources/skills.js +6 -1
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/secrets/remote.d.ts +7 -2
- package/dist/lib/secrets/remote.js +11 -10
- package/dist/lib/session/db.d.ts +3 -0
- package/dist/lib/session/db.js +20 -4
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +40 -4
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/staleness/detectors/permissions.js +22 -1
- package/dist/lib/staleness/detectors/subagents.js +11 -11
- package/dist/lib/staleness/writers/commands.js +3 -3
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/subagents.d.ts +1 -0
- package/dist/lib/subagents.js +15 -12
- package/package.json +1 -1
|
@@ -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).
|
package/dist/lib/permissions.js
CHANGED
|
@@ -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
|
|
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
|
|
1039
|
-
|
|
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
|
-
|
|
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') {
|
package/dist/lib/project-root.js
CHANGED
|
@@ -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[]
|
|
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
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
function osForTarget(target) {
|
|
29
|
-
|
|
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
|
});
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -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';
|
package/dist/lib/session/db.js
CHANGED
|
@@ -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 =
|
|
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. */
|