@phnx-labs/agents-cli 1.20.58 → 1.20.60
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 +23 -1
- package/README.md +15 -7
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +39 -2
- 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 +19 -13
- package/dist/lib/hosts/dispatch.d.ts +36 -0
- package/dist/lib/hosts/dispatch.js +40 -2
- 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 +26 -5
- package/dist/lib/permissions.js +212 -37
- package/dist/lib/plugins.d.ts +8 -0
- package/dist/lib/plugins.js +108 -0
- package/dist/lib/project-root.js +2 -1
- package/dist/lib/resources/mcp.js +1 -1
- package/dist/lib/resources/permissions.d.ts +1 -1
- package/dist/lib/resources/permissions.js +8 -2
- package/dist/lib/resources/skills.js +6 -1
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/routines.d.ts +16 -0
- package/dist/lib/routines.js +46 -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/shims.js +13 -3
- package/dist/lib/skills.js +14 -1
- package/dist/lib/staleness/detectors/permissions.js +50 -3
- package/dist/lib/staleness/detectors/subagents.js +31 -12
- package/dist/lib/staleness/detectors/workflows.js +33 -0
- package/dist/lib/staleness/writers/commands.js +3 -3
- package/dist/lib/staleness/writers/subagents.js +13 -5
- 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 +11 -1
- package/dist/lib/subagents.js +117 -26
- package/dist/lib/versions.js +12 -2
- package/dist/lib/workflows.d.ts +5 -3
- package/dist/lib/workflows.js +246 -9
- package/package.json +1 -1
package/dist/lib/routines.js
CHANGED
|
@@ -187,7 +187,52 @@ export function writeJob(config) {
|
|
|
187
187
|
const devArr = output.devices;
|
|
188
188
|
if (!devArr || devArr.length === 0)
|
|
189
189
|
delete output.devices;
|
|
190
|
-
|
|
190
|
+
let existingText = null;
|
|
191
|
+
if (ymlExists || yamlExists) {
|
|
192
|
+
try {
|
|
193
|
+
existingText = fs.readFileSync(filePath, 'utf-8');
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
existingText = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
atomicWriteFileSync(filePath, serializeJob(output, existingText));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Serialize a job config, preserving the on-disk formatting of an existing file.
|
|
203
|
+
*
|
|
204
|
+
* A full `yaml.stringify(config)` re-emits the whole document — restyling every
|
|
205
|
+
* scalar (unquoting `schedule`, re-wrapping the folded `prompt` block, reordering
|
|
206
|
+
* keys). When a routine is only being toggled (pause/resume) or re-pinned
|
|
207
|
+
* (`devices --set`), that rewrites the entire file, leaving the git-backed
|
|
208
|
+
* `~/.agents` tree perpetually dirty so `agents repo pull` refuses to sync across
|
|
209
|
+
* the fleet. To keep the diff to the field that actually changed, we edit the
|
|
210
|
+
* existing document in place and only re-render touched nodes; untouched nodes
|
|
211
|
+
* (notably the large `prompt` block) keep their byte-for-byte formatting.
|
|
212
|
+
*
|
|
213
|
+
* `existingText` is the current file contents, or null for a new file. New,
|
|
214
|
+
* unparseable, and non-mapping documents fall back to canonical `yaml.stringify`.
|
|
215
|
+
*/
|
|
216
|
+
export function serializeJob(output, existingText) {
|
|
217
|
+
if (existingText == null)
|
|
218
|
+
return yaml.stringify(output);
|
|
219
|
+
const doc = yaml.parseDocument(existingText);
|
|
220
|
+
if (doc.errors.length > 0 || !yaml.isMap(doc.contents))
|
|
221
|
+
return yaml.stringify(output);
|
|
222
|
+
const existing = (doc.toJS() ?? {});
|
|
223
|
+
// Update or add only the keys that actually changed; leave the rest untouched
|
|
224
|
+
// so their original formatting is preserved.
|
|
225
|
+
for (const [key, value] of Object.entries(output)) {
|
|
226
|
+
if (JSON.stringify(existing[key]) !== JSON.stringify(value))
|
|
227
|
+
doc.set(key, value);
|
|
228
|
+
}
|
|
229
|
+
// Drop keys that no longer belong (e.g. an omitted default, or `devices`
|
|
230
|
+
// cleared back to fleet-wide).
|
|
231
|
+
for (const key of Object.keys(existing)) {
|
|
232
|
+
if (!(key in output))
|
|
233
|
+
doc.delete(key);
|
|
234
|
+
}
|
|
235
|
+
return doc.toString();
|
|
191
236
|
}
|
|
192
237
|
/** Delete a job config file by name. Returns true if the file existed. */
|
|
193
238
|
export function deleteJob(name) {
|
|
@@ -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. */
|
|
@@ -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
|
-
|
|
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;
|
|
@@ -66,6 +66,8 @@ export interface SessionMeta {
|
|
|
66
66
|
gitBranch?: string;
|
|
67
67
|
messageCount?: number;
|
|
68
68
|
tokenCount?: number;
|
|
69
|
+
/** Real generated (output) tokens — excludes cache-read/-write context (issue: `agents output`). */
|
|
70
|
+
outputTokens?: number;
|
|
69
71
|
/** Total USD cost, computed at scan time from per-model token usage (issue #323). */
|
|
70
72
|
costUsd?: number;
|
|
71
73
|
/** Wall-clock duration in ms (lastTs − firstTs), persisted at scan time. */
|
package/dist/lib/shims.js
CHANGED
|
@@ -2024,18 +2024,28 @@ export function releaseAdoptedLauncher(agent, overrides) {
|
|
|
2024
2024
|
// records written before this format existed.
|
|
2025
2025
|
const launcher = lines[1] || getPathShadowingExecutable(agent) || original;
|
|
2026
2026
|
const shimReal = canonical(path.join(shimsDir, AGENTS[agent].cliCommand));
|
|
2027
|
+
const shimPath = path.resolve(path.join(shimsDir, AGENTS[agent].cliCommand));
|
|
2027
2028
|
try {
|
|
2028
2029
|
// Only rewrite the launcher if it currently points at our shim (i.e. we own
|
|
2029
2030
|
// it). If the user has since replaced it themselves, leave it alone.
|
|
2030
2031
|
let pointsAtShim = false;
|
|
2031
2032
|
try {
|
|
2032
|
-
|
|
2033
|
-
|
|
2033
|
+
const stat = fs.lstatSync(launcher);
|
|
2034
|
+
if (stat.isSymbolicLink()) {
|
|
2035
|
+
const target = fs.readlinkSync(launcher);
|
|
2036
|
+
const absoluteTarget = path.resolve(path.dirname(launcher), target);
|
|
2037
|
+
pointsAtShim = canonicalOrNull(launcher) === shimReal || absoluteTarget === shimPath;
|
|
2038
|
+
}
|
|
2034
2039
|
}
|
|
2035
2040
|
catch { /* launcher gone — recreate below */ }
|
|
2036
2041
|
if (pointsAtShim || !fs.existsSync(launcher)) {
|
|
2037
2042
|
try {
|
|
2038
|
-
fs.
|
|
2043
|
+
if (fs.lstatSync(launcher).isSymbolicLink()) {
|
|
2044
|
+
fs.unlinkSync(launcher);
|
|
2045
|
+
}
|
|
2046
|
+
else {
|
|
2047
|
+
fs.rmSync(launcher, { force: true });
|
|
2048
|
+
}
|
|
2039
2049
|
}
|
|
2040
2050
|
catch { /* may not exist */ }
|
|
2041
2051
|
fs.symlinkSync(original, launcher);
|
package/dist/lib/skills.js
CHANGED
|
@@ -10,7 +10,7 @@ import * as fs from 'fs';
|
|
|
10
10
|
import * as path from 'path';
|
|
11
11
|
import * as os from 'os';
|
|
12
12
|
import * as yaml from 'yaml';
|
|
13
|
-
import { ensureSkillsDir, agentConfigDirName } from './agents.js';
|
|
13
|
+
import { AGENTS, ensureSkillsDir, agentConfigDirName } from './agents.js';
|
|
14
14
|
import { capableAgents, isCapable } from './capabilities.js';
|
|
15
15
|
import { getUserSkillsDir, getSkillsDir as getSystemSkillsDir, getProjectAgentsDir, getEnabledExtraRepos, getTrashSkillsDir } from './state.js';
|
|
16
16
|
import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
|
|
@@ -432,6 +432,19 @@ function versionSkillMatches(agent, version, skillName) {
|
|
|
432
432
|
*/
|
|
433
433
|
export function diffVersionSkills(agent, version) {
|
|
434
434
|
const available = new Set(listAllSkills());
|
|
435
|
+
// Goose and other native ~/.agents/skills consumers read central storage
|
|
436
|
+
// directly. They intentionally have no per-version copy to diff, so every
|
|
437
|
+
// available central skill is already current for every supported version.
|
|
438
|
+
if (AGENTS[agent].nativeAgentsSkillsDir) {
|
|
439
|
+
return {
|
|
440
|
+
agent,
|
|
441
|
+
version,
|
|
442
|
+
toAdd: [],
|
|
443
|
+
toUpdate: [],
|
|
444
|
+
matched: Array.from(available).sort(),
|
|
445
|
+
orphans: [],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
435
448
|
const installed = new Set(listSkillsInVersionHome(agent, version));
|
|
436
449
|
const toAdd = [];
|
|
437
450
|
const toUpdate = [];
|
|
@@ -84,7 +84,7 @@ function buildOpenCodeDetector() {
|
|
|
84
84
|
kind: 'permissions',
|
|
85
85
|
agent: 'opencode',
|
|
86
86
|
list({ versionHome }) {
|
|
87
|
-
const opencodeConfigPath = path.join(versionHome, '.opencode', 'opencode.jsonc');
|
|
87
|
+
const opencodeConfigPath = path.join(versionHome, '.config', 'opencode', 'opencode.jsonc');
|
|
88
88
|
if (!fs.existsSync(opencodeConfigPath))
|
|
89
89
|
return [];
|
|
90
90
|
try {
|
|
@@ -110,8 +110,11 @@ function buildGeminiDetector() {
|
|
|
110
110
|
return [];
|
|
111
111
|
try {
|
|
112
112
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
113
|
-
const
|
|
114
|
-
|
|
113
|
+
const core = settings?.tools?.core;
|
|
114
|
+
const exclude = settings?.tools?.exclude;
|
|
115
|
+
const hasCore = Array.isArray(core) && core.length > 0;
|
|
116
|
+
const hasExclude = Array.isArray(exclude) && exclude.length > 0;
|
|
117
|
+
if (hasCore || hasExclude) {
|
|
115
118
|
return discoverPermissionGroups().map(g => g.name);
|
|
116
119
|
}
|
|
117
120
|
}
|
|
@@ -162,6 +165,28 @@ function buildGrokDetector() {
|
|
|
162
165
|
},
|
|
163
166
|
};
|
|
164
167
|
}
|
|
168
|
+
function buildGooseDetector() {
|
|
169
|
+
return {
|
|
170
|
+
kind: 'permissions',
|
|
171
|
+
agent: 'goose',
|
|
172
|
+
list({ versionHome }) {
|
|
173
|
+
const permissionsPath = path.join(versionHome, '.config', 'goose', 'permission.yaml');
|
|
174
|
+
if (!fs.existsSync(permissionsPath))
|
|
175
|
+
return [];
|
|
176
|
+
try {
|
|
177
|
+
const config = yaml.parse(fs.readFileSync(permissionsPath, 'utf-8'));
|
|
178
|
+
const user = config?.user;
|
|
179
|
+
const count = (Array.isArray(user?.always_allow) ? user.always_allow.length : 0) +
|
|
180
|
+
(Array.isArray(user?.ask_before) ? user.ask_before.length : 0) +
|
|
181
|
+
(Array.isArray(user?.never_allow) ? user.never_allow.length : 0);
|
|
182
|
+
if (count > 0)
|
|
183
|
+
return discoverPermissionGroups().map(g => g.name);
|
|
184
|
+
}
|
|
185
|
+
catch { /* parse fail */ }
|
|
186
|
+
return [];
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
165
190
|
function buildKimiDetector() {
|
|
166
191
|
return {
|
|
167
192
|
kind: 'permissions',
|
|
@@ -202,6 +227,26 @@ function buildCursorDetector() {
|
|
|
202
227
|
},
|
|
203
228
|
};
|
|
204
229
|
}
|
|
230
|
+
function buildDroidDetector() {
|
|
231
|
+
return {
|
|
232
|
+
kind: 'permissions',
|
|
233
|
+
agent: 'droid',
|
|
234
|
+
list({ versionHome }) {
|
|
235
|
+
const settingsPath = path.join(versionHome, '.factory', 'settings.json');
|
|
236
|
+
if (!fs.existsSync(settingsPath))
|
|
237
|
+
return [];
|
|
238
|
+
try {
|
|
239
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
240
|
+
const hasAllow = Array.isArray(settings.commandAllowlist) && settings.commandAllowlist.length > 0;
|
|
241
|
+
const hasDeny = Array.isArray(settings.commandDenylist) && settings.commandDenylist.length > 0;
|
|
242
|
+
if (hasAllow || hasDeny)
|
|
243
|
+
return discoverPermissionGroups().map(g => g.name);
|
|
244
|
+
}
|
|
245
|
+
catch { /* parse fail */ }
|
|
246
|
+
return [];
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
205
250
|
function buildKiroDetector() {
|
|
206
251
|
return {
|
|
207
252
|
kind: 'permissions',
|
|
@@ -228,8 +273,10 @@ const handlers = {
|
|
|
228
273
|
gemini: buildGeminiDetector,
|
|
229
274
|
antigravity: buildAntigravityDetector,
|
|
230
275
|
grok: buildGrokDetector,
|
|
276
|
+
goose: buildGooseDetector,
|
|
231
277
|
kimi: buildKimiDetector,
|
|
232
278
|
cursor: buildCursorDetector,
|
|
279
|
+
droid: buildDroidDetector,
|
|
233
280
|
kiro: buildKiroDetector,
|
|
234
281
|
};
|
|
235
282
|
export const permissionsDetectors = lazyAgentMap(() => {
|