@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 CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.59
6
+
7
+ - **Fix: remote secrets now choose the Windows PowerShell wrapper from the original `--host` name, not the resolved `user@ip` SSH target.** Inline enrolled Windows hosts resolve to address-based SSH targets, but the OS registry is keyed by the host name; `agents secrets view/list/exec --host <windows>`, `agents run --secrets bundle@<windows>`, and remote secrets unlock/export paths now pass that original name into command construction so Windows hosts no longer fall back to `bash -lc`. Source: `apps/cli/src/lib/secrets/remote.ts`, `apps/cli/src/commands/secrets.ts`, `apps/cli/src/commands/exec.ts`. (RUSH-1431)
8
+ - **Wire Droid skills support (RUSH-1397).** Droid loads skills from `.factory/skills/` (since 0.26.0). Flip `skills: { since: '0.26.0' }`, register generic skills writer/detector. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/__tests__/capabilities.test.ts`.
9
+ - **Wire skills support for Goose CLI (RUSH-1394).** Goose reads skills directly from `~/.agents/skills/` via the Summon extension (block-goose-cli >= 1.25.0). Flip `skills: { since: '1.25.0' }`, register generic skills writer/detector. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/staleness/writers/commands.ts`.
10
+ - **Wire Droid allowlist support (RUSH-1396).** Droid stores allow/deny in `.factory/settings.json` (`commandAllowlist`/`commandDenylist`). Flip `allowlist: true` since 0.57.5, add `convertToDroidFormat`, wire writer/detector. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/permissions.ts`.
11
+ - **Wire Codex permissions/allowlist support (RUSH-1566).** Codex stores allow/deny in `.codex/config.toml` (`approval_policy`, `sandbox_mode`). Flip `allowlist: true` since 0.128.0, add `convertToCodexFormat`, wire writer/detector. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/permissions.ts`.
12
+ - **OpenCode permissions write to the loaded config path (RUSH-1623).** Global config is `~/.config/opencode/opencode.jsonc` (not `~/.opencode/`); project config is `opencode.jsonc` at the project root. Source: `apps/cli/src/lib/permissions.ts`, `apps/cli/src/lib/agents.ts`.
13
+ - **Catch-up publishes now require the exact CI-tested release tree and its full green matrix.** A package version already present on `main` no longer counts as release validation by itself: `release.sh` resolves the merged `release/v<version>` PR, requires its merge commit to be current `main`, fetches the PR head that ran CI, requires that head tree to equal current `main`, and rechecks every expected CI context before publishing. This closes the path that let 1.20.58 reach npm before its tag-triggered Windows matrix exposed failures. Source: `apps/cli/scripts/release.sh`.
14
+ - **Windows release validation now matches portable path behavior.** Home-relative project paths normalize native separators to `/` before they are stored as `~/…`, and the systemd-manifest and remote-shell assertions now compare the escaped/quoted forms that the runtime deliberately emits. This restores the Windows Node 22/24 release matrix without weakening command escaping. Source: `apps/cli/src/lib/project-root.ts`, `apps/cli/src/lib/{project-root,daemon}.test.ts`, `apps/cli/src/lib/hosts/dispatch.test.ts`.
5
15
  ## 1.20.58
6
16
 
7
17
  - **Self-updating agent CLIs are represented as one live installation.** `agents view` no longer invents version-home rows for single-binary installers such as Droid, Grok, Cursor, Kiro, Goose, and Hermes; it reports the version returned by the installed binary and folds away stale per-version directories. `agents add <agent>@<version>` now installs or keeps that agent's current release instead of rejecting an unsupported pinned install. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/versions.ts`, `apps/cli/src/commands/{versions,view}.ts`. (RUSH-1321)
package/README.md CHANGED
@@ -855,9 +855,10 @@ Which DotAgents resources each agent CLI can load. Source of truth: [src/lib/age
855
855
  | OpenCode | yes | no | yes | no | yes | yes | no | no | `AGENTS.md` | no |
856
856
  | Copilot | yes | no | yes | no | yes | yes | no | no | `AGENTS.md` | no |
857
857
  | Amp | yes | no | yes | no | yes | yes | no | no | `AGENTS.md` | no |
858
- | Kiro | yes | no | yes | >= 2.8.0 | yes | yes | no | no | `AGENTS.md` | no |
858
+ | Kiro | yes | no | yes | >= 2.8.0 | yes | yes | no | >= 1.23.0 | `AGENTS.md` | no |
859
859
  | Goose | yes | no | yes | no | no | no | no | no | `AGENTS.md` | no |
860
860
  | Roo Code | yes | no | yes | no | yes | yes | no | no | `AGENTS.md` | no |
861
+ | Droid | yes | yes | yes | >= 0.57.5 | >= 0.26.0 | yes | yes | yes | `AGENTS.md` | no |
861
862
 
862
863
  **Legend:** `yes` / `no` = synced or skipped at install time. `skills ($name)` = no file-based slash-command dir; behavior ships as a generated skill invoked with `$command`. `gateway` = OpenClaw resolves slash commands at runtime, not from synced files. Version suffixes are enforced at sync time — out-of-range versions are skipped with a clear message.
863
864
 
@@ -875,13 +876,17 @@ Which DotAgents resources each agent CLI can load. Source of truth: [src/lib/age
875
876
  | Grok Build | -- | yes | yes |
876
877
  | Antigravity | -- | yes | -- |
877
878
  | Copilot | -- | -- | yes |
878
- | OpenClaw, Amp, Kiro, Goose, Roo | -- | -- | -- |
879
+ | OpenClaw, Amp, Goose, Roo | -- | -- | -- |
880
+ | Kiro | -- | -- | -- |
879
881
 
880
882
  ### Version-gated sync
881
883
 
882
884
  | Capability | Agent | Gate |
883
885
  |------------|-------|------|
884
886
  | Hooks | Codex | >= 0.116.0 |
887
+ | Subagents | Kiro | >= 1.23.0 |
888
+ | Skills | Droid | >= 0.26.0 |
889
+ | Permissions | Droid | >= 0.57.5 |
885
890
  | Hooks | Gemini | >= 0.26.0 |
886
891
  | Permissions | Kiro | >= 2.8.0 |
887
892
  | File-based commands | Codex | < 0.117.0 (0.117+ uses command-as-skill) |
package/dist/bin/agents CHANGED
Binary file
@@ -1227,7 +1227,7 @@ export function registerRunCommand(program) {
1227
1227
  // Remote bundle (`bundle@host`): resolve over SSH and inject
1228
1228
  // ephemerally — values never touch this machine's keychain or disk.
1229
1229
  const target = await resolveSshTarget(host);
1230
- const bundleEnv = await remoteResolveEnv(target, bundleName);
1230
+ const bundleEnv = await remoteResolveEnv(target, bundleName, { osLookupName: host });
1231
1231
  console.log(chalk.gray(`[secrets] Resolved ${bundleName}@${host}: ${Object.keys(bundleEnv).length} keys (remote, ephemeral)`));
1232
1232
  secretsEnv = { ...secretsEnv, ...bundleEnv };
1233
1233
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Output command — productivity: token *burn* vs shipped *output*.
3
+ *
4
+ * `agents cost` answers "what did we burn?" (dollars + duration). This joins that
5
+ * burn to what actually shipped — real generated (output) tokens plus PRs and
6
+ * commits across every git identity — so you can see burn-vs-output and ratios
7
+ * like $/PR and output-tokens/$. Pure SQLite + local git/gh, no server, no
8
+ * telemetry — the same offline spirit as `cost`.
9
+ *
10
+ * Why not just show `token_count`? Because that number sums cache-read/-write
11
+ * context re-counted every turn and is dominated by cheap re-reads (often ~100x
12
+ * the real generation). `output_tokens` (scanned per-agent into the session DB)
13
+ * is the honest "work produced" signal, and it is what this command leads with.
14
+ *
15
+ * `--all-hosts` fans the same rollup across every online device (`ag devices`)
16
+ * over SSH and merges — one fleet-wide burn-vs-output view.
17
+ */
18
+ import type { Command } from 'commander';
19
+ export declare function registerOutputCommand(program: Command): void;
@@ -0,0 +1,333 @@
1
+ import chalk from 'chalk';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { execFile } from 'child_process';
5
+ import { promisify } from 'util';
6
+ import { addHostOption } from '../lib/hosts/option.js';
7
+ import { discoverSessions, parseTimeFilter } from '../lib/session/discover.js';
8
+ import { queryUsageRollup } from '../lib/session/db.js';
9
+ import { formatUsd, PRICING_VERSION } from '../lib/pricing/index.js';
10
+ import { formatDuration } from '../lib/session/render.js';
11
+ import { terminalWidth, truncateToWidth, padToWidth } from '../lib/session/width.js';
12
+ import { collectGitOutput } from '../lib/output/git-output.js';
13
+ import { loadDevices } from '../lib/devices/registry.js';
14
+ import { machineId } from '../lib/session/sync/config.js';
15
+ const execFileAsync = promisify(execFile);
16
+ export function registerOutputCommand(program) {
17
+ addHostOption(program.command('output'))
18
+ .description('Productivity rollup — token burn vs shipped output (PRs, commits) across agents')
19
+ .option('--json', 'Output the rollup as JSON')
20
+ .option('--since <time>', 'Only sessions/commits newer than this: 1h, 24h, 7d, 4w, 1mo, 1y, or ISO date (default 7d)')
21
+ .option('--by <dimension>', 'Group the burn/output breakdown by: agent (default), project, or day')
22
+ .option('--repos-dir <dir>', 'Root scanned for git repos (default ~/src)')
23
+ .option('--author <email...>', 'Count commits by these author emails (default: your git identities)')
24
+ .option('--login <login...>', 'Count PRs for these GitHub logins (default: current gh user)')
25
+ .option('--no-prs', 'Skip the GitHub PR lookup (commits only)')
26
+ .option('--all-hosts', 'Aggregate across every online device (ag devices) over SSH')
27
+ .addHelpText('after', `
28
+ Examples:
29
+ agents output Last 7 days: burn, output tokens, PRs, commits, ratios
30
+ agents output --since 24h Last 24 hours
31
+ agents output --since 1mo Last month (units: 1h 24h 7d 4w 1mo 1y, or ISO date)
32
+ agents output --all-hosts Fleet-wide, folding in every online machine
33
+ agents output --by day --json Machine-readable daily burn/output rollup
34
+
35
+ Burn (cost) is computed offline from a versioned per-model price table (${PRICING_VERSION}).
36
+ Output tokens are the real generated tokens — NOT the cache-inflated total token count.
37
+ `)
38
+ .action(async (options) => {
39
+ await outputAction(options);
40
+ });
41
+ }
42
+ function resolveGroup(by) {
43
+ if (by === undefined)
44
+ return 'agent';
45
+ if (by === 'agent' || by === 'project' || by === 'day')
46
+ return by;
47
+ console.error(chalk.red('error: --by must be one of: agent, project, day'));
48
+ process.exit(1);
49
+ }
50
+ /** Compact token formatter: 38.6M, 4.1K, 10.6B. */
51
+ function formatCompact(n) {
52
+ const abs = Math.abs(n);
53
+ if (abs >= 1e9)
54
+ return `${(n / 1e9).toFixed(1)}B`;
55
+ if (abs >= 1e6)
56
+ return `${(n / 1e6).toFixed(1)}M`;
57
+ if (abs >= 1e3)
58
+ return `${(n / 1e3).toFixed(1)}K`;
59
+ return String(n);
60
+ }
61
+ function emptyBurn() {
62
+ return { costUsd: 0, outputTokens: 0, tokenCount: 0, sessionCount: 0, durationMs: 0 };
63
+ }
64
+ function sumBurn(rows) {
65
+ return rows.reduce((acc, r) => {
66
+ acc.costUsd += r.costUsd;
67
+ acc.outputTokens += r.outputTokens;
68
+ acc.tokenCount += r.tokenCount;
69
+ acc.sessionCount += r.sessionCount;
70
+ acc.durationMs += r.durationMs;
71
+ return acc;
72
+ }, emptyBurn());
73
+ }
74
+ /** Compute this machine's payload from the local session DB + git. */
75
+ async function computeLocalPayload(options, includePrs) {
76
+ const since = options.since ?? '7d';
77
+ const sinceMs = parseTimeFilter(since);
78
+ // Ensure the index is fresh (and migrated to v12 so output_tokens is populated).
79
+ await discoverSessions({ all: true, since, limit: 1 });
80
+ const filter = { sinceMs };
81
+ const groupBy = resolveGroup(options.by);
82
+ const breakdown = queryUsageRollup({ ...filter, groupBy });
83
+ const burn = sumBurn(breakdown);
84
+ const reposDir = options.reposDir ?? path.join(os.homedir(), 'src');
85
+ const git = await collectGitOutput({
86
+ reposDir,
87
+ sinceMs,
88
+ authors: options.author,
89
+ logins: options.login,
90
+ includePrs,
91
+ });
92
+ return {
93
+ machine: machineId(),
94
+ pricingVersion: PRICING_VERSION,
95
+ since,
96
+ burn,
97
+ output: {
98
+ commits: git.commits,
99
+ commitShas: git.commitShas,
100
+ prsOpened: git.prsOpened,
101
+ prsMerged: git.prsMerged,
102
+ reposScanned: git.reposScanned,
103
+ ghAvailable: git.ghAvailable,
104
+ authors: git.authors,
105
+ logins: git.logins,
106
+ },
107
+ breakdown: { by: groupBy, rows: breakdown },
108
+ uncostedAgents: groupBy === 'agent' ? breakdown.filter(r => r.costUsd === 0).map(r => r.key) : [],
109
+ };
110
+ }
111
+ /** Fetch one remote device's payload by re-invoking `agents output --json --host <name>`. */
112
+ async function fetchRemotePayload(device, options) {
113
+ const args = ['output', '--json', '--no-prs', '--host', device, '--since', options.since ?? '7d'];
114
+ if (options.by)
115
+ args.push('--by', options.by);
116
+ if (options.reposDir)
117
+ args.push('--repos-dir', options.reposDir);
118
+ for (const a of options.author ?? [])
119
+ args.push('--author', a);
120
+ try {
121
+ const { stdout } = await execFileAsync('agents', args, {
122
+ timeout: 120_000,
123
+ maxBuffer: 64 * 1024 * 1024,
124
+ });
125
+ const parsed = JSON.parse(stdout);
126
+ parsed.machine = parsed.machine || device;
127
+ return parsed;
128
+ }
129
+ catch (err) {
130
+ return {
131
+ machine: device,
132
+ pricingVersion: PRICING_VERSION,
133
+ since: options.since ?? '7d',
134
+ burn: emptyBurn(),
135
+ output: { commits: 0, commitShas: [], prsOpened: 0, prsMerged: 0, reposScanned: 0, ghAvailable: false, authors: [], logins: [] },
136
+ breakdown: { by: resolveGroup(options.by), rows: [] },
137
+ uncostedAgents: [],
138
+ error: (err?.stderr || err?.message || 'unreachable').toString().split('\n')[0].slice(0, 120),
139
+ };
140
+ }
141
+ }
142
+ async function outputAction(options) {
143
+ const includePrs = options.prs !== false;
144
+ if (!options.allHosts) {
145
+ const payload = await computeLocalPayload(options, includePrs);
146
+ if (options.json) {
147
+ process.stdout.write(JSON.stringify(withRatios(payload, [payload]), null, 2) + '\n');
148
+ return;
149
+ }
150
+ renderSingle(payload);
151
+ return;
152
+ }
153
+ // Fleet: local payload + every online device, folded in over SSH.
154
+ const self = machineId();
155
+ const registry = await loadDevices();
156
+ const remotes = Object.values(registry)
157
+ .filter(d => d.tailscale?.online && d.name !== self)
158
+ .map(d => d.name);
159
+ if (!options.json)
160
+ console.error(chalk.gray(`Folding in ${remotes.length} online device${remotes.length !== 1 ? 's' : ''}…`));
161
+ // PRs are global (gh search by author, not machine-bound) — compute once, locally.
162
+ const local = await computeLocalPayload(options, includePrs);
163
+ const remotePayloads = await Promise.all(remotes.map(d => fetchRemotePayload(d, options)));
164
+ const machines = [local, ...remotePayloads];
165
+ if (options.json) {
166
+ process.stdout.write(JSON.stringify(withRatios(mergeMachines(machines, options), machines), null, 2) + '\n');
167
+ return;
168
+ }
169
+ renderFleet(machines, options);
170
+ }
171
+ /** Merge per-machine payloads into a combined one (burn + commits summed; PRs local-only). */
172
+ function mergeMachines(machines, options) {
173
+ const burn = emptyBurn();
174
+ const byKey = new Map();
175
+ // Union commit SHAs across machines: shared repos (cloned on several boxes)
176
+ // expose the same commits to `git log` on each, so summing counts would
177
+ // multi-count. A commit's SHA is its global identity — union, don't add.
178
+ const allShas = new Set();
179
+ const uncosted = new Set();
180
+ for (const m of machines) {
181
+ burn.costUsd += m.burn.costUsd;
182
+ burn.outputTokens += m.burn.outputTokens;
183
+ burn.tokenCount += m.burn.tokenCount;
184
+ burn.sessionCount += m.burn.sessionCount;
185
+ burn.durationMs += m.burn.durationMs;
186
+ for (const s of m.output.commitShas)
187
+ allShas.add(s);
188
+ for (const a of m.uncostedAgents)
189
+ uncosted.add(a);
190
+ for (const r of m.breakdown.rows) {
191
+ const cur = byKey.get(r.key) ?? { key: r.key, costUsd: 0, durationMs: 0, sessionCount: 0, tokenCount: 0, outputTokens: 0 };
192
+ cur.costUsd += r.costUsd;
193
+ cur.durationMs += r.durationMs;
194
+ cur.sessionCount += r.sessionCount;
195
+ cur.tokenCount += r.tokenCount;
196
+ cur.outputTokens += r.outputTokens;
197
+ byKey.set(r.key, cur);
198
+ }
199
+ }
200
+ const rows = [...byKey.values()].sort((a, b) => b.costUsd - a.costUsd);
201
+ // PRs from the local machine only (machines[0]) — gh search is not machine-scoped.
202
+ const localGit = machines[0].output;
203
+ return {
204
+ machine: 'fleet',
205
+ pricingVersion: PRICING_VERSION,
206
+ since: options.since ?? '7d',
207
+ burn,
208
+ output: { ...localGit, commits: allShas.size, commitShas: [...allShas] },
209
+ breakdown: { by: resolveGroup(options.by), rows },
210
+ uncostedAgents: [...uncosted],
211
+ };
212
+ }
213
+ /** Attach burn-vs-output ratios to a payload for JSON output. */
214
+ function withRatios(payload, machines) {
215
+ const prsTotal = payload.output.prsOpened + payload.output.prsMerged;
216
+ return {
217
+ ...payload,
218
+ ratios: {
219
+ costPerPr: prsTotal > 0 ? payload.burn.costUsd / prsTotal : null,
220
+ costPerCommit: payload.output.commits > 0 ? payload.burn.costUsd / payload.output.commits : null,
221
+ outputTokensPerUsd: payload.burn.costUsd > 0 ? payload.burn.outputTokens / payload.burn.costUsd : null,
222
+ },
223
+ machines: machines.length > 1 ? machines.map(m => ({ machine: m.machine, burn: m.burn, commits: m.output.commits, error: m.error })) : undefined,
224
+ };
225
+ }
226
+ /** Shared header line: burned · output tokens · PRs · commits, plus ratios. */
227
+ function headerLines(payload) {
228
+ const prsTotal = payload.output.prsOpened + payload.output.prsMerged;
229
+ const out = [];
230
+ out.push(' ' +
231
+ `${chalk.green(formatUsd(payload.burn.costUsd))} burned` +
232
+ chalk.gray(' · ') +
233
+ `${chalk.cyan(formatCompact(payload.burn.outputTokens))} output tokens` +
234
+ chalk.gray(' · ') +
235
+ `${chalk.yellow(String(prsTotal))} PRs ${chalk.gray(`(${payload.output.prsMerged} merged)`)}` +
236
+ chalk.gray(' · ') +
237
+ `${chalk.yellow(String(payload.output.commits))} commits`);
238
+ const ratios = [];
239
+ if (prsTotal > 0)
240
+ ratios.push(`${formatUsd(payload.burn.costUsd / prsTotal)}/PR`);
241
+ if (payload.output.commits > 0)
242
+ ratios.push(`${formatUsd(payload.burn.costUsd / payload.output.commits)}/commit`);
243
+ if (payload.burn.costUsd > 0)
244
+ ratios.push(`${formatCompact(Math.round(payload.burn.outputTokens / payload.burn.costUsd))} out-tok/$`);
245
+ if (ratios.length > 0)
246
+ out.push(chalk.gray(' ' + ratios.join(' · ')));
247
+ return out;
248
+ }
249
+ /** Render the per-group burn/output table. */
250
+ function renderBreakdown(rows, groupBy) {
251
+ const out = [chalk.bold(`By ${groupBy}`)];
252
+ if (rows.length === 0)
253
+ return out;
254
+ const cols = terminalWidth();
255
+ const burnW = Math.max(...rows.map(r => formatUsd(r.costUsd).length), 4);
256
+ const outW = Math.max(...rows.map(r => formatCompact(r.outputTokens).length), 6);
257
+ const sessW = Math.max(...rows.map(r => String(r.sessionCount).length), 3);
258
+ const fixedW = 2 + 2 + burnW + 2 + outW + 2 + sessW + 8;
259
+ const keyW = Math.max(8, Math.min(Math.max(...rows.map(r => r.key.length), groupBy.length), cols - fixedW));
260
+ out.push(' ' + chalk.gray(padToWidth('', keyW)) + ' ' + chalk.gray(padToWidth('burn', burnW)) + ' ' + chalk.gray(padToWidth('output', outW)) + ' ' + chalk.gray('sessions'));
261
+ for (const r of rows) {
262
+ out.push(' ' +
263
+ padToWidth(truncateToWidth(r.key, keyW), keyW) +
264
+ ' ' +
265
+ chalk.green(padToWidth(formatUsd(r.costUsd), burnW)) +
266
+ ' ' +
267
+ chalk.cyan(padToWidth(formatCompact(r.outputTokens), outW)) +
268
+ ' ' +
269
+ chalk.gray(padToWidth(String(r.sessionCount), sessW)));
270
+ }
271
+ return out;
272
+ }
273
+ function renderSingle(payload) {
274
+ const out = [];
275
+ out.push(chalk.bold('Output') + chalk.gray(` · pricing ${payload.pricingVersion} · since ${payload.since}`));
276
+ out.push(...headerLines(payload));
277
+ out.push('');
278
+ if (payload.burn.sessionCount === 0) {
279
+ out.push(chalk.gray('No sessions with cost data found. Run `agents sessions --all` to index, then retry.'));
280
+ console.log(out.join('\n'));
281
+ return;
282
+ }
283
+ out.push(...renderBreakdown(payload.breakdown.rows, payload.breakdown.by));
284
+ out.push('');
285
+ out.push(chalk.bold('Shipped'));
286
+ out.push(` ${chalk.yellow(String(payload.output.commits))} commits across ${payload.output.reposScanned} repos` + chalk.gray(` (authors: ${payload.output.authors.length > 0 ? payload.output.authors.join(', ') : 'none detected'})`));
287
+ if (payload.output.ghAvailable) {
288
+ out.push(` ${chalk.yellow(String(payload.output.prsOpened))} PRs opened, ${chalk.yellow(String(payload.output.prsMerged))} merged` + chalk.gray(` (logins: ${payload.output.logins.join(', ')})`));
289
+ }
290
+ else {
291
+ out.push(chalk.gray(' PRs: gh unavailable or unauthed — not counted'));
292
+ }
293
+ out.push('');
294
+ out.push(chalk.gray(notCountedLine(payload.uncostedAgents)));
295
+ if (payload.burn.durationMs > 0)
296
+ out.push(chalk.gray(`agent wall-clock: ${formatDuration(payload.burn.durationMs)}`));
297
+ console.log(out.join('\n'));
298
+ }
299
+ function renderFleet(machines, options) {
300
+ const merged = mergeMachines(machines, options);
301
+ const out = [];
302
+ out.push(chalk.bold('Output') + chalk.gray(` · fleet (${machines.length} machines) · pricing ${merged.pricingVersion} · since ${merged.since}`));
303
+ out.push(...headerLines(merged));
304
+ out.push('');
305
+ // By machine.
306
+ out.push(chalk.bold('By machine'));
307
+ const nameW = Math.max(...machines.map(m => m.machine.length), 7);
308
+ const burnW = Math.max(...machines.map(m => formatUsd(m.burn.costUsd).length), 4);
309
+ for (const m of machines) {
310
+ const note = m.error ? chalk.red(` (${m.error})`) : '';
311
+ out.push(' ' +
312
+ padToWidth(m.machine, nameW) +
313
+ ' ' +
314
+ chalk.green(padToWidth(formatUsd(m.burn.costUsd), burnW)) +
315
+ ' ' +
316
+ chalk.cyan(padToWidth(formatCompact(m.burn.outputTokens), 7)) +
317
+ ' ' +
318
+ chalk.gray(`${m.burn.sessionCount} sessions, ${m.output.commits} commits`) +
319
+ note);
320
+ }
321
+ out.push('');
322
+ out.push(...renderBreakdown(merged.breakdown.rows, merged.breakdown.by));
323
+ out.push('');
324
+ out.push(chalk.gray(notCountedLine(merged.uncostedAgents)));
325
+ console.log(out.join('\n'));
326
+ }
327
+ function notCountedLine(uncosted) {
328
+ const notes = [];
329
+ if (uncosted.length > 0)
330
+ notes.push(`no price table for: ${[...new Set(uncosted)].join(', ')} (burn undercounts)`);
331
+ notes.push('cloud runs (Rush/Codex/Factory) not counted');
332
+ return 'not counted: ' + notes.join(' · ');
333
+ }
@@ -328,12 +328,12 @@ async function browseRemote(targets, args, tty) {
328
328
  if (tty) {
329
329
  for (const t of targets) {
330
330
  const target = await resolveSshTarget(t);
331
- render(t, remoteSecretsRaw(target, args, { tty: true }));
331
+ render(t, remoteSecretsRaw(target, args, { tty: true, osLookupName: t }));
332
332
  }
333
333
  }
334
334
  else {
335
- const resolved = await Promise.all(targets.map((t) => resolveSshTarget(t)));
336
- const results = resolved.map((target) => remoteSecretsRaw(target, args));
335
+ const resolved = await Promise.all(targets.map(async (t) => ({ name: t, target: await resolveSshTarget(t) })));
336
+ const results = resolved.map(({ name, target }) => remoteSecretsRaw(target, args, { osLookupName: name }));
337
337
  targets.forEach((t, i) => render(t, results[i]));
338
338
  }
339
339
  if (failures > 0)
@@ -1476,7 +1476,7 @@ Examples:
1476
1476
  // engine (BatchMode, ConnectTimeout, keepalive, control-socket
1477
1477
  // reuse) via the same path the READ inverse (`remoteResolveEnv`)
1478
1478
  // uses. `--from -` reads the .env off ssh stdin.
1479
- res = remoteSecretsRaw(host, ['import', resolvedBundleName, '--from', '-', ...(opts.force ? ['--force'] : [])], { input: dotenv });
1479
+ res = remoteSecretsRaw(host, ['import', resolvedBundleName, '--from', '-', ...(opts.force ? ['--force'] : [])], { input: dotenv, osLookupName: host });
1480
1480
  }
1481
1481
  if (res.code === null) {
1482
1482
  failures++;
@@ -1584,7 +1584,7 @@ Examples:
1584
1584
  // locally instead.
1585
1585
  const { assertRemoteBundleFlagsUnsupported } = await import('../lib/secrets/bundles.js');
1586
1586
  assertRemoteBundleFlagsUnsupported(bundleName, execOpts.host, { keys: keysSubset, allowExpired: execOpts.allowExpired }, { keysFlag: '--keys', allowExpiredFlag: '--allow-expired' });
1587
- secretEnv = await remoteResolveEnv(await resolveSshTarget(execOpts.host), bundleName);
1587
+ secretEnv = await remoteResolveEnv(await resolveSshTarget(execOpts.host), bundleName, { osLookupName: execOpts.host });
1588
1588
  }
1589
1589
  else {
1590
1590
  const { readAndResolveBundleEnv } = await import('../lib/secrets/bundles.js');
@@ -1738,7 +1738,7 @@ Examples:
1738
1738
  // sees a real TTY, which requires our local terminal to pass straight
1739
1739
  // through. The remote's prompt + output stream to this terminal; we get
1740
1740
  // back only the exit code.
1741
- const code = remoteSecretsStream(target, unlockArgs);
1741
+ const code = remoteSecretsStream(target, unlockArgs, { osLookupName: h });
1742
1742
  if (code === 0) {
1743
1743
  console.log(chalk.green(`${h}: unlocked`));
1744
1744
  }
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ if (IS_DEV_BUILD) {
50
50
  // module on each invocation (which loaded the whole ~50-module tree before the
51
51
  // first byte of output), the registry maps a command name to a thunk that
52
52
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
53
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadFeed, } from './lib/startup/command-registry.js';
53
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadFeed, } from './lib/startup/command-registry.js';
54
54
  import { applyGlobalHelpConventions } from './lib/help.js';
55
55
  import { renderWhatsNew } from './lib/whats-new.js';
56
56
  import { emit, redactArgs } from './lib/events.js';
@@ -690,6 +690,7 @@ async function registerAllEagerCommands() {
690
690
  await reg(loadFactory);
691
691
  await reg(loadUsage);
692
692
  await reg(loadCost);
693
+ await reg(loadOutput);
693
694
  await reg(loadBudget);
694
695
  await reg(loadAlias);
695
696
  await reg(loadPty);
@@ -236,7 +236,7 @@ export const AGENTS = {
236
236
  cloudProvider: 'codex',
237
237
  // Subagents: multi-agent plumbing since 0.117.0; custom agents as
238
238
  // ~/.codex/agents/*.toml (name, description, developer_instructions).
239
- capabilities: { hooks: { since: '0.116.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: false, skills: true, commands: { until: '0.117.0' }, plugins: { since: '0.128.0' }, subagents: { since: '0.117.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: true, modes: ['plan', 'edit', 'skip'] },
239
+ capabilities: { hooks: { since: '0.116.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: { since: '0.138.0' }, skills: true, commands: { until: '0.117.0' }, plugins: { since: '0.128.0' }, subagents: { since: '0.117.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: true, modes: ['plan', 'edit', 'skip'] },
240
240
  },
241
241
  gemini: {
242
242
  id: 'gemini',
@@ -405,7 +405,10 @@ export const AGENTS = {
405
405
  configDir: path.join(HOME, '.config', 'goose'),
406
406
  commandsDir: path.join(HOME, '.config', 'goose', 'commands'),
407
407
  commandsSubdir: 'commands',
408
- skillsDir: path.join(HOME, '.config', 'goose', 'skills'),
408
+ // Goose reads skills directly from central ~/.agents/skills/ via the Summon
409
+ // extension (block-goose-cli ≥ 1.25.0). No per-version copy is written.
410
+ skillsDir: path.join(HOME, '.agents', 'skills'),
411
+ nativeAgentsSkillsDir: true,
409
412
  // Hooks: Open Plugins format — auto-discovered from
410
413
  // ~/.agents/plugins/<name>/hooks/hooks.json (shipped block-goose-cli
411
414
  // ≥ 1.34.0). See registerHooksForGoose.
@@ -416,7 +419,7 @@ export const AGENTS = {
416
419
  supportsHooks: true,
417
420
  // Plugins: Open Plugins under ~/.agents/plugins/<name>/ (same layout as
418
421
  // agents-cli source). Version isolation copies into versionHome/.agents/plugins/.
419
- capabilities: { hooks: { since: '1.34.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: false, commands: false, plugins: true, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['edit'] },
422
+ capabilities: { hooks: { since: '1.34.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: { since: '1.25.0' }, commands: false, plugins: true, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['edit'] },
420
423
  },
421
424
  // Google Antigravity CLI (`agy`) — official replacement for Gemini CLI as of IO 2026.
422
425
  // configDir nests inside `~/.gemini/` since agy shares the parent dir with the Gemini
@@ -560,7 +563,7 @@ export const AGENTS = {
560
563
  authFiles: ['auth.v2.file', 'auth.v2.key'],
561
564
  commandsDir: path.join(HOME, '.factory', 'commands'),
562
565
  commandsSubdir: 'commands',
563
- skillsDir: '', // no skills concept
566
+ skillsDir: path.join(HOME, '.factory', 'skills'),
564
567
  hooksDir: 'hooks',
565
568
  pluginManifestDir: '.factory-plugin',
566
569
  instructionsFile: 'AGENTS.md',
@@ -575,8 +578,8 @@ export const AGENTS = {
575
578
  mcp: true,
576
579
  mcpHttp: false,
577
580
  mcpHeaders: false,
578
- allowlist: false,
579
- skills: false,
581
+ allowlist: { since: '0.57.5' },
582
+ skills: { since: '0.26.0' },
580
583
  commands: true,
581
584
  plugins: true,
582
585
  subagents: true,
@@ -1826,8 +1829,8 @@ export function getUserMcpConfigPath(agentId) {
1826
1829
  // Codex uses TOML config
1827
1830
  return path.join(agent.configDir, 'config.toml');
1828
1831
  case 'opencode':
1829
- // OpenCode uses JSONC config
1830
- return path.join(agent.configDir, 'opencode.jsonc');
1832
+ // OpenCode loads ~/.config/opencode/opencode.jsonc (not ~/.opencode/)
1833
+ return path.join(HOME, '.config', 'opencode', 'opencode.jsonc');
1831
1834
  case 'cursor':
1832
1835
  // Cursor uses mcp.json
1833
1836
  return path.join(agent.configDir, 'mcp.json');
@@ -1865,7 +1868,7 @@ export function getMcpConfigPathForHome(agentId, home) {
1865
1868
  case 'codex':
1866
1869
  return path.join(home, '.codex', 'config.toml');
1867
1870
  case 'opencode':
1868
- return path.join(home, '.opencode', 'opencode.jsonc');
1871
+ return path.join(home, '.config', 'opencode', 'opencode.jsonc');
1869
1872
  case 'cursor':
1870
1873
  return path.join(home, '.cursor', 'mcp.json');
1871
1874
  case 'openclaw':
@@ -1903,7 +1906,8 @@ function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
1903
1906
  case 'codex':
1904
1907
  return path.join(cwd, `.${agentId}`, 'config.toml');
1905
1908
  case 'opencode':
1906
- return path.join(cwd, `.${agentId}`, 'opencode.jsonc');
1909
+ // Project config is opencode.jsonc at project root (not .opencode/)
1910
+ return path.join(cwd, 'opencode.jsonc');
1907
1911
  case 'cursor':
1908
1912
  return path.join(cwd, `.${agentId}`, 'mcp.json');
1909
1913
  case 'openclaw':
@@ -28,6 +28,7 @@ const REMOTE_PASSTHROUGH = {
28
28
  view: {},
29
29
  usage: {},
30
30
  cost: {},
31
+ output: {},
31
32
  doctor: {},
32
33
  inspect: {},
33
34
  list: {},
package/dist/lib/mcp.js CHANGED
@@ -485,7 +485,7 @@ function installMcpToForgeConfig(server, versionHome) {
485
485
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
486
486
  }
487
487
  function installMcpToOpenCodeConfig(server, versionHome) {
488
- const configPath = path.join(versionHome, '.opencode', 'opencode.jsonc');
488
+ const configPath = path.join(versionHome, '.config', 'opencode', 'opencode.jsonc');
489
489
  let config = {};
490
490
  if (fs.existsSync(configPath)) {
491
491
  const content = fs.readFileSync(configPath, 'utf-8');
@@ -0,0 +1,74 @@
1
+ /** Commit tally for one author email. */
2
+ export interface AuthorCommits {
3
+ author: string;
4
+ commits: number;
5
+ }
6
+ /** The shipped-work rollup for a window. */
7
+ export interface GitOutputSummary {
8
+ reposScanned: number;
9
+ commits: number;
10
+ byAuthor: AuthorCommits[];
11
+ prsOpened: number;
12
+ prsMerged: number;
13
+ /**
14
+ * Deduped commit SHAs authored by us in the window. Carried so `--all-hosts`
15
+ * can UNION across machines — a repo cloned on several boxes exposes the same
16
+ * commits to `git log` on each, so summing counts would multi-count.
17
+ */
18
+ commitShas: string[];
19
+ /** false when `gh` is missing/unauthed — PR counts are then 0 and not trustworthy. */
20
+ ghAvailable: boolean;
21
+ /** Author emails counted as "ours". */
22
+ authors: string[];
23
+ /** gh logins queried for PRs. */
24
+ logins: string[];
25
+ sinceIso: string;
26
+ }
27
+ export interface GitOutputOptions {
28
+ /** Root scanned for git repos (e.g. ~/src). */
29
+ reposDir: string;
30
+ /** Window start, epoch ms. */
31
+ sinceMs: number;
32
+ /** Restrict commit authorship to these emails; default: identities discovered from git config. */
33
+ authors?: string[];
34
+ /** gh logins to search PRs for; default: the current `gh api user` login. */
35
+ logins?: string[];
36
+ /** Repo discovery depth below reposDir (default 4 — covers ~/src/host/org/repo). */
37
+ maxDepth?: number;
38
+ /** Query PRs via gh (default true). */
39
+ includePrs?: boolean;
40
+ }
41
+ /**
42
+ * Find git repositories under `root` up to `maxDepth` levels deep. A directory
43
+ * with a `.git` entry is a repo and is NOT descended into (so nested worktrees
44
+ * / submodules don't double-count).
45
+ */
46
+ export declare function findGitRepos(root: string, maxDepth?: number): string[];
47
+ /**
48
+ * Count commits by our authors across all repos, deduped by SHA (so the same
49
+ * commit reachable via multiple repo clones/worktrees on this machine — or, at
50
+ * the fleet layer, across machines — is counted once), tallied per email.
51
+ */
52
+ export declare function collectCommits(repos: string[], sinceIso: string, authors: string[]): Promise<{
53
+ total: number;
54
+ byAuthor: AuthorCommits[];
55
+ shas: string[];
56
+ }>;
57
+ /**
58
+ * Count PRs opened and merged in the window across the given logins. `gh search
59
+ * prs` searches all of GitHub, so one authed gh can cover multiple accounts'
60
+ * logins. Returns ghAvailable=false if gh can't be reached at all.
61
+ */
62
+ export declare function collectPrs(logins: string[], sinceDate: string): Promise<{
63
+ opened: number;
64
+ merged: number;
65
+ logins: string[];
66
+ ghAvailable: boolean;
67
+ }>;
68
+ /** Format an epoch-ms as a YYYY-MM-DD date (UTC), for gh's date-range search. */
69
+ export declare function toSearchDate(ms: number): string;
70
+ /**
71
+ * Collect the full shipped-work summary for a window: commits (across accounts)
72
+ * plus PRs opened/merged.
73
+ */
74
+ export declare function collectGitOutput(options: GitOutputOptions): Promise<GitOutputSummary>;