@phnx-labs/agents-cli 1.20.91 → 1.20.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +1 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +72 -6
  5. package/dist/commands/activity.js +198 -49
  6. package/dist/commands/beta.js +1 -0
  7. package/dist/commands/doctor.js +4 -2
  8. package/dist/commands/events.js +91 -1
  9. package/dist/commands/exec.d.ts +14 -0
  10. package/dist/commands/exec.js +144 -14
  11. package/dist/commands/projects.d.ts +22 -0
  12. package/dist/commands/projects.js +539 -0
  13. package/dist/commands/secrets.d.ts +17 -0
  14. package/dist/commands/secrets.js +198 -7
  15. package/dist/commands/send.d.ts +14 -12
  16. package/dist/commands/send.js +105 -35
  17. package/dist/commands/sessions-picker.d.ts +15 -0
  18. package/dist/commands/sessions-picker.js +37 -12
  19. package/dist/commands/sessions-resume.d.ts +2 -0
  20. package/dist/commands/sessions-resume.js +9 -1
  21. package/dist/commands/sessions.d.ts +10 -5
  22. package/dist/commands/sessions.js +65 -27
  23. package/dist/commands/sync.js +9 -3
  24. package/dist/commands/view.js +4 -0
  25. package/dist/index.js +18 -1
  26. package/dist/lib/activity.d.ts +77 -12
  27. package/dist/lib/activity.js +424 -74
  28. package/dist/lib/beta.d.ts +1 -1
  29. package/dist/lib/beta.js +1 -1
  30. package/dist/lib/channels/send.d.ts +83 -0
  31. package/dist/lib/channels/send.js +112 -0
  32. package/dist/lib/devices/registry.d.ts +14 -0
  33. package/dist/lib/devices/registry.js +37 -0
  34. package/dist/lib/events-ingest.d.ts +46 -0
  35. package/dist/lib/events-ingest.js +182 -0
  36. package/dist/lib/events.d.ts +15 -3
  37. package/dist/lib/events.js +55 -3
  38. package/dist/lib/feed-post.js +8 -2
  39. package/dist/lib/hosts/remote-cmd.js +4 -0
  40. package/dist/lib/linear-project-counts.d.ts +62 -0
  41. package/dist/lib/linear-project-counts.js +122 -0
  42. package/dist/lib/linear-projects.d.ts +50 -0
  43. package/dist/lib/linear-projects.js +114 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  46. package/dist/lib/menubar/install-menubar.js +20 -6
  47. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  48. package/dist/lib/menubar/notify-desktop.js +8 -2
  49. package/dist/lib/project-key.d.ts +44 -0
  50. package/dist/lib/project-key.js +79 -0
  51. package/dist/lib/project-probe.d.ts +75 -0
  52. package/dist/lib/project-probe.js +160 -0
  53. package/dist/lib/project-resources.d.ts +8 -0
  54. package/dist/lib/project-resources.js +31 -3
  55. package/dist/lib/project-root.js +16 -0
  56. package/dist/lib/project-status.d.ts +100 -0
  57. package/dist/lib/project-status.js +182 -0
  58. package/dist/lib/projects.d.ts +144 -0
  59. package/dist/lib/projects.js +313 -0
  60. package/dist/lib/remote-agents-json.d.ts +9 -0
  61. package/dist/lib/remote-agents-json.js +11 -5
  62. package/dist/lib/routine-notify.d.ts +11 -0
  63. package/dist/lib/routine-notify.js +22 -0
  64. package/dist/lib/run-notify.js +3 -0
  65. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  66. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  67. package/dist/lib/secrets/audit.d.ts +1 -1
  68. package/dist/lib/secrets/audit.js +53 -10
  69. package/dist/lib/secrets/list-filter.d.ts +20 -5
  70. package/dist/lib/secrets/list-filter.js +22 -6
  71. package/dist/lib/secrets/usage-db.d.ts +106 -0
  72. package/dist/lib/secrets/usage-db.js +236 -0
  73. package/dist/lib/session/bash-command.d.ts +53 -0
  74. package/dist/lib/session/bash-command.js +364 -0
  75. package/dist/lib/session/digest.d.ts +6 -0
  76. package/dist/lib/session/digest.js +19 -0
  77. package/dist/lib/session/relative-time.d.ts +23 -0
  78. package/dist/lib/session/relative-time.js +60 -8
  79. package/dist/lib/session/remote-active.d.ts +5 -1
  80. package/dist/lib/session/remote-active.js +4 -1
  81. package/dist/lib/session/remote-list.js +5 -2
  82. package/dist/lib/session/render.d.ts +2 -9
  83. package/dist/lib/session/render.js +25 -56
  84. package/dist/lib/sqlite.js +28 -1
  85. package/dist/lib/ssh-exec.d.ts +6 -0
  86. package/dist/lib/ssh-exec.js +10 -1
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/state.d.ts +14 -0
  90. package/dist/lib/state.js +19 -0
  91. package/dist/lib/terminal/backends/index.d.ts +10 -2
  92. package/dist/lib/terminal/backends/index.js +14 -2
  93. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  94. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  95. package/dist/lib/terminal/index.d.ts +2 -1
  96. package/dist/lib/terminal/index.js +2 -1
  97. package/dist/lib/terminal/preferred.d.ts +89 -0
  98. package/dist/lib/terminal/preferred.js +87 -0
  99. package/dist/lib/terminal/run-surface.d.ts +82 -0
  100. package/dist/lib/terminal/run-surface.js +146 -0
  101. package/dist/lib/terminal/types.d.ts +1 -1
  102. package/dist/lib/types.d.ts +6 -5
  103. package/dist/lib/versions.d.ts +6 -0
  104. package/dist/lib/versions.js +6 -4
  105. package/package.json +2 -1
@@ -0,0 +1,539 @@
1
+ /**
2
+ * `agents projects` — named, multi-repo projects and the progress
3
+ * rollup. Definitions live in `~/.agents/projects/<name>.yaml` (see
4
+ * `lib/projects.ts`); this registers the command tree over them. Beta-gated on
5
+ * `isBetaEnabled('projects')`, mirroring `agents factory`.
6
+ *
7
+ * The headline is `status`: instead of the vague per-agent activity line, it
8
+ * rolls every session up by project (matched on cwd) into one card — agents by
9
+ * lifecycle state, plan completion, open PRs, and tickets in flight.
10
+ */
11
+ import chalk from 'chalk';
12
+ import * as fs from 'fs';
13
+ import { execFileSync, spawnSync } from 'child_process';
14
+ import { betaEnableHint, isBetaEnabled } from '../lib/beta.js';
15
+ import { setHelpSections } from '../lib/help.js';
16
+ import { getMainRepoRoot } from '../lib/git.js';
17
+ import { parseOwnerRepoFromRemote } from '../lib/registry.js';
18
+ import { toHomeRelative } from '../lib/project-root.js';
19
+ import { machineId } from '../lib/machine-id.js';
20
+ import { getActiveSessions } from '../lib/session/active.js';
21
+ import { gatherRemoteActive } from '../lib/session/remote-active.js';
22
+ import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
23
+ import { factoryProjectsPath } from '../lib/auto-dispatch.js';
24
+ import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, } from '../lib/project-probe.js';
25
+ import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
26
+ import { rollupSessionsByProject, planPct, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
27
+ import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
28
+ import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
29
+ /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
30
+ export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
31
+ /** Max peers named in the skipped note before the rest collapse to `+N`. */
32
+ const SKIPPED_NAME_LIMIT = 4;
33
+ /**
34
+ * One compact trailing note for peers that didn't answer the `--fleet`
35
+ * fan-out — unreachable, running an agents-cli too old to carry `projects
36
+ * probe`, or too slow to finish inside the 12s SSH budget. Mirrors
37
+ * `formatUnreachableNote` (activity) with the probe-specific reasons; empty
38
+ * string when everything answered.
39
+ */
40
+ export function formatFleetSkippedNote(skipped) {
41
+ if (skipped.length === 0)
42
+ return '';
43
+ const named = skipped.slice(0, SKIPPED_NAME_LIMIT);
44
+ const rest = skipped.length - named.length;
45
+ const list = rest > 0 ? `${named.join(', ')} +${rest}` : named.join(', ');
46
+ const noun = skipped.length === 1 ? 'device' : 'devices';
47
+ return chalk.gray(` · ${skipped.length} ${noun} didn't answer (unreachable, older agents-cli, or timed out): ${list}\n`);
48
+ }
49
+ /** `path:purpose` → a context anchor. Purpose may contain colons. */
50
+ function parseContextFlag(raw) {
51
+ const i = raw.indexOf(':');
52
+ if (i === -1)
53
+ return { path: raw.trim(), purpose: '' };
54
+ return { path: raw.slice(0, i).trim(), purpose: raw.slice(i + 1).trim() };
55
+ }
56
+ /** Best-effort `owner/repo` from a repo's origin remote. */
57
+ function originSlug(cwd) {
58
+ try {
59
+ const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8' }).trim();
60
+ return parseOwnerRepoFromRemote(url) ?? undefined;
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ function statusBar(r) {
67
+ const parts = [];
68
+ const push = (n, label, color) => {
69
+ if (n && n > 0)
70
+ parts.push(color(`${n} ${label}`));
71
+ };
72
+ push(r.byStatus.running, 'running', chalk.green);
73
+ push(r.byStatus.idle, 'idle', chalk.gray);
74
+ push(r.byStatus.input_required, 'need-input', chalk.yellow);
75
+ push(r.byStatus.queued, 'queued', chalk.gray);
76
+ const shown = (r.byStatus.running ?? 0) + (r.byStatus.idle ?? 0) + (r.byStatus.input_required ?? 0) + (r.byStatus.queued ?? 0);
77
+ // Sessions in a non-live state (orphaned/crashed/unknown) count toward the
78
+ // headline — render the remainder so the bar never sums to less than it.
79
+ if (r.agents > shown)
80
+ parts.push(chalk.gray(`+${r.agents - shown} other`));
81
+ return parts.join(' · ') || chalk.gray('no live agents');
82
+ }
83
+ function renderCard(def, r, remote, fleet, linear) {
84
+ const agents = r?.agents ?? 0;
85
+ const pct = r ? planPct(r.plan) : undefined;
86
+ const planStr = pct === undefined ? '' : ` · ${chalk.cyan(`${pct}% plan`)}`;
87
+ console.log(`${chalk.bold(def.name)} ${chalk.dim('·')} ${chalk.bold(`${agents} agents`)}${planStr}`);
88
+ if (def.description)
89
+ console.log(` ${chalk.dim(def.description)}`);
90
+ console.log(` ${chalk.dim('live')} ${r ? statusBar(r) : chalk.gray('no live agents')}`);
91
+ if (r && r.members.length)
92
+ console.log(` ${chalk.dim('agents')} ${formatProjectMembers(r.members)}`);
93
+ const ships = [];
94
+ if (remote?.mergedPrs)
95
+ ships.push(chalk.green(`${remote.mergedPrs} merged (${remote.windowDays}d)`));
96
+ if (r?.openPrs.length)
97
+ ships.push(`${r.openPrs.length} open PR${r.openPrs.length === 1 ? '' : 's'}`);
98
+ if (r?.worktrees)
99
+ ships.push(`${r.worktrees} worktree${r.worktrees === 1 ? '' : 's'}`);
100
+ if (remote?.latestRelease)
101
+ ships.push(remote.latestRelease.tag);
102
+ if (ships.length)
103
+ console.log(` ${chalk.dim('ships')} ${ships.join(' · ')}`);
104
+ if (linear) {
105
+ console.log(` ${chalk.dim('linear')} ${linear.done}/${linear.total}${linear.truncated ? '+' : ''} done · ${linear.inProgress} in progress`);
106
+ }
107
+ if (r && r.tickets.length) {
108
+ console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
109
+ }
110
+ if (fleet) {
111
+ const lines = formatFleetWorkspaces(fleet);
112
+ if (lines.length === 0) {
113
+ console.log(` ${chalk.dim('fleet')} ${chalk.gray('no workspace paths (set root or repos[].path)')}`);
114
+ }
115
+ lines.forEach((line, i) => {
116
+ console.log(` ${chalk.dim((i === 0 ? 'fleet' : '').padEnd(5))} ${line}`);
117
+ });
118
+ }
119
+ if (remote?.artifacts) {
120
+ const last = remote.lastArtifact ? ` ${chalk.dim(`· last: ${remote.lastArtifact}`)}` : '';
121
+ console.log(` ${chalk.dim('proof')} ${remote.artifacts} artifact${remote.artifacts === 1 ? '' : 's'} (${remote.windowDays}d)${last}`);
122
+ }
123
+ const repos = [def.repo, ...(def.repos ?? []).map((x) => x.slug)].filter(Boolean);
124
+ if (repos.length)
125
+ console.log(` ${chalk.dim('repos')} ${[...new Set(repos)].join(' · ')}`);
126
+ if (def.contexts?.length) {
127
+ console.log(` ${chalk.dim('context')} ${def.contexts.map((c) => c.path).join(' · ')}`);
128
+ }
129
+ if (def.integrations?.length) {
130
+ console.log(` ${chalk.dim('links')} ${def.integrations.map((i) => i.label ?? i.kind).join(' · ')}`);
131
+ }
132
+ console.log('');
133
+ }
134
+ export function registerProjectsCommands(program) {
135
+ const enabled = isBetaEnabled('projects');
136
+ const projects = program
137
+ .command('projects', { hidden: !enabled })
138
+ .description('Named multi-repo projects with a progress rollup.');
139
+ setHelpSections(projects, {
140
+ examples: `
141
+ agents projects add rush --repo phnx-labs/rush --path apps/web
142
+ agents projects list
143
+ agents projects status # progress card for every project
144
+ agents projects status rush --json # one project, machine-readable
145
+ agents projects status --fleet # + per-device workspace drift over SSH
146
+ agents projects link rush --linear # bind the Linear project (auto-suggest)
147
+ agents run --project rush # land an agent in the project
148
+ `,
149
+ notes: `
150
+ Definitions are hand-editable YAML in ~/.agents/projects/ and sync across
151
+ machines with 'agents push/pull'. Enable with: agents beta enable projects.
152
+ `,
153
+ });
154
+ projects.hook('preAction', (_thisCommand, actionCommand) => {
155
+ if (enabled)
156
+ return;
157
+ // `probe` is the peer half of `status --fleet`: it must answer whenever the
158
+ // binary carries it, even where the beta flag is off — a gated peer would
159
+ // look unreachable to every fleet member on a newer CLI.
160
+ if (actionCommand.name() === 'probe')
161
+ return;
162
+ console.error(chalk.red('agents projects is in beta.'));
163
+ console.error(chalk.gray(betaEnableHint('projects')));
164
+ process.exit(1);
165
+ });
166
+ // ---- list ----
167
+ projects
168
+ .command('list')
169
+ .description('List defined projects with their root, repo, and live agent count.')
170
+ .option('--json', 'Machine-readable output')
171
+ .action(async (opts) => {
172
+ const defs = listProjectDefs();
173
+ if (opts.json) {
174
+ const roll = rollupSessionsByProject(defs, await getActiveSessions());
175
+ console.log(JSON.stringify(defs.map((d) => ({ ...d, agents: roll.get(d.name)?.agents ?? 0 })), null, 2));
176
+ return;
177
+ }
178
+ if (!defs.length) {
179
+ console.log(chalk.gray('No projects defined. Add one: agents projects add <name>'));
180
+ return;
181
+ }
182
+ const roll = rollupSessionsByProject(defs, await getActiveSessions());
183
+ for (const d of defs) {
184
+ const agents = roll.get(d.name)?.agents ?? 0;
185
+ const repo = d.repo ?? d.repos?.[0]?.slug ?? '';
186
+ console.log(` ${chalk.bold(d.name.padEnd(16))} ${chalk.dim((d.root ?? d.defaultPath ?? '').padEnd(32))} ${chalk.cyan(repo.padEnd(24))} ${agents} agents`);
187
+ }
188
+ });
189
+ // ---- add ----
190
+ projects
191
+ .command('add <name>')
192
+ .description('Define a project. Infers root and repo from the current git repo when not given.')
193
+ .option('--root <path>', 'Repo / monorepo root (defaults to the current git repo root)')
194
+ .option('--path <subdir>', 'Default cwd for agents (a monorepo subdir)')
195
+ .option('--repo <owner/repo>', 'Primary GitHub slug (defaults to the origin remote)')
196
+ .option('--context <path:purpose...>', 'A described starting point; repeatable')
197
+ .option('--linear <url-or-id>', 'Linear project URL or id')
198
+ .option('--force', 'Overwrite an existing definition')
199
+ .action(async (name, opts) => {
200
+ if (!isSafeProjectName(name)) {
201
+ console.error(chalk.red(`Invalid project name: "${name}" (letters, digits, ., _, - only)`));
202
+ process.exit(1);
203
+ }
204
+ if (loadProjectDef(name) && !opts.force) {
205
+ console.error(chalk.red(`Project "${name}" already exists. Use --force to overwrite, or 'agents projects edit ${name}'.`));
206
+ process.exit(1);
207
+ }
208
+ const cwd = process.cwd();
209
+ let root = opts.root;
210
+ if (!root) {
211
+ try {
212
+ root = toHomeRelative(await getMainRepoRoot(cwd));
213
+ }
214
+ catch {
215
+ console.error(chalk.red('Not inside a git repo — pass --root <path> explicitly.'));
216
+ process.exit(1);
217
+ }
218
+ }
219
+ const repo = opts.repo ?? originSlug(cwd);
220
+ const def = { name, root };
221
+ if (opts.path)
222
+ def.defaultPath = `${root.replace(/\/$/, '')}/${opts.path.replace(/^\//, '')}`;
223
+ if (repo)
224
+ def.repo = repo;
225
+ if (opts.context?.length)
226
+ def.contexts = opts.context.map(parseContextFlag);
227
+ if (opts.linear) {
228
+ def.linear = /^https?:/.test(opts.linear) ? { url: opts.linear } : { projectId: opts.linear };
229
+ }
230
+ const target = writeProjectDef(def);
231
+ console.log(chalk.green(`Defined project "${name}"`));
232
+ console.log(chalk.gray(` ${target}`));
233
+ console.log(chalk.gray(` root ${def.root}${def.repo ? ` · repo ${def.repo}` : ''}`));
234
+ });
235
+ // ---- show ----
236
+ projects
237
+ .command('show <name>')
238
+ .description('Show a project definition, resolved paths, repos, contexts, and links.')
239
+ .option('--json', 'Machine-readable output')
240
+ .action((name, opts) => {
241
+ const def = loadProjectDef(name);
242
+ if (!def) {
243
+ console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
244
+ process.exit(1);
245
+ }
246
+ if (opts.json) {
247
+ console.log(JSON.stringify(def, null, 2));
248
+ return;
249
+ }
250
+ console.log(chalk.bold(def.name) + (def.description ? chalk.dim(` — ${def.description}`) : ''));
251
+ if (def.root)
252
+ console.log(` root ${def.root}`);
253
+ if (def.defaultPath)
254
+ console.log(` defaultPath ${def.defaultPath}`);
255
+ const repos = [def.repo, ...(def.repos ?? []).map((r) => (r.subpath ? `${r.slug} (${r.subpath})` : r.slug))].filter(Boolean);
256
+ if (repos.length)
257
+ console.log(` repos ${repos.join(', ')}`);
258
+ for (const c of def.contexts ?? [])
259
+ console.log(` context ${chalk.cyan(c.path)} — ${c.purpose}`);
260
+ for (const i of def.integrations ?? [])
261
+ console.log(` ${i.kind.padEnd(12)} ${i.url}${i.label ? chalk.dim(` (${i.label})`) : ''}`);
262
+ if (def.linear?.url || def.linear?.projectId)
263
+ console.log(` linear ${def.linear.url ?? def.linear.projectId}`);
264
+ for (const d of def.docs ?? [])
265
+ console.log(` doc ${d}`);
266
+ console.log(chalk.gray(` ${projectDefPath(name)}`));
267
+ });
268
+ // ---- edit ----
269
+ projects
270
+ .command('edit <name>')
271
+ .description('Open the project YAML in $EDITOR (it is hand-editable regardless).')
272
+ .action((name) => {
273
+ const target = projectDefPath(name);
274
+ if (!fs.existsSync(target)) {
275
+ console.error(chalk.red(`No project named "${name}". Create it: agents projects add ${name}`));
276
+ process.exit(1);
277
+ }
278
+ const editor = process.env.VISUAL || process.env.EDITOR || 'vi';
279
+ // $EDITOR commonly carries args ("code --wait") — split like monitors/routines do.
280
+ const parts = editor.split(/\s+/).filter(Boolean);
281
+ const res = spawnSync(parts[0], [...parts.slice(1), target], { stdio: 'inherit' });
282
+ process.exit(res.status ?? 0);
283
+ });
284
+ // ---- status ----
285
+ projects
286
+ .command('status [name]')
287
+ .description('Progress rollup: agents, plan %, merged/open PRs, tickets, and artifacts per project.')
288
+ .option('--json', 'Machine-readable output')
289
+ .option('--window <days>', 'Window for merged PRs and artifacts', '7')
290
+ .option('--no-remote', 'Skip the GitHub lookup (merged-PR count); faster, offline')
291
+ .option('--fleet', 'Also dial every fleet device for workspace presence, branch, and drift (one SSH per peer)')
292
+ .action(async (name, opts) => {
293
+ const all = listProjectDefs();
294
+ // Named lookup goes through the strict single-def loader so a broken
295
+ // <name>.yaml surfaces its validation error instead of "No project named".
296
+ const defs = name ? [loadProjectDef(name)].filter((d) => d !== undefined) : all;
297
+ if (name && !defs.length) {
298
+ console.error(chalk.red(`No project named "${name}".`));
299
+ process.exit(1);
300
+ }
301
+ if (!defs.length) {
302
+ if (opts.json)
303
+ console.log('[]');
304
+ else
305
+ console.log(chalk.gray('No projects defined. Add one: agents projects add <name>'));
306
+ return;
307
+ }
308
+ const windowDays = Math.max(1, Number.parseInt(opts.window ?? '7', 10) || 7);
309
+ const nowMs = Date.now();
310
+ // --fleet: probe each shown def's workspace paths (root + repos[].path)
311
+ // locally and on every peer in one parallel SSH round, and widen the
312
+ // live-session rollup to the whole fleet via the existing sessions
313
+ // fan-out. Both are opt-in — they dial the fleet.
314
+ const fleetTargets = opts.fleet ? [...new Set(defs.flatMap(workspaceTargetsForDef))] : [];
315
+ let fleetWs = [];
316
+ let fleetSkipped = [];
317
+ let fleetSessions = [];
318
+ if (opts.fleet) {
319
+ const self = machineId();
320
+ fleetWs.push(...probeProjectWorkspaces(fleetTargets).map((s) => ({ ...s, host: self })));
321
+ const [probeRes, activeRes] = await Promise.all([
322
+ fleetTargets.length > 0
323
+ ? gatherRemoteAgentsJson({
324
+ args: ['projects', 'probe', '--json', ...fleetTargets],
325
+ noFanoutEnv: PROJECTS_NO_FANOUT_ENV,
326
+ parse: parseRemoteProbe,
327
+ quiet: true,
328
+ })
329
+ : Promise.resolve({ items: [], deviceCount: 0, skipped: [] }),
330
+ gatherRemoteActive(undefined, { quiet: true }),
331
+ ]);
332
+ fleetWs.push(...probeRes.items);
333
+ fleetSkipped = probeRes.skipped;
334
+ fleetSessions = activeRes.sessions;
335
+ }
336
+ const roll = rollupSessionsByProject(all, [...await getActiveSessions(), ...fleetSessions]);
337
+ // Enrich only the shown projects. --no-remote still reads the local artifact
338
+ // log but skips the gh calls + Linear counts (both are network).
339
+ const remote = new Map();
340
+ const linear = new Map();
341
+ await Promise.all(defs.map(async (d) => {
342
+ const skipRemote = opts.remote === false;
343
+ const [sig, counts] = await Promise.all([
344
+ enrichProjectSignals(d, windowDays, nowMs, { skipRemote }),
345
+ !skipRemote && d.linear?.projectId
346
+ ? fetchLinearProjectCounts(d.linear.projectId)
347
+ : Promise.resolve(undefined),
348
+ ]);
349
+ remote.set(d.name, sig);
350
+ if (counts)
351
+ linear.set(d.name, counts);
352
+ }));
353
+ /** This def's slice of the fleet probe, in its own target order. */
354
+ const fleetFor = (d) => {
355
+ const targets = new Set(workspaceTargetsForDef(d));
356
+ return fleetWs.filter((s) => targets.has(s.path));
357
+ };
358
+ if (opts.json) {
359
+ if (fleetSkipped.length > 0)
360
+ process.stderr.write(formatFleetSkippedNote(fleetSkipped));
361
+ console.log(JSON.stringify(defs.map((d) => {
362
+ const r = roll.get(d.name);
363
+ const rem = remote.get(d.name);
364
+ return {
365
+ name: d.name,
366
+ agents: r?.agents ?? 0,
367
+ byStatus: r?.byStatus ?? {},
368
+ members: r?.members ?? [],
369
+ plan: r?.plan ?? { done: 0, total: 0 },
370
+ planPct: r ? planPct(r.plan) ?? null : null,
371
+ openPrs: r?.openPrs ?? [],
372
+ mergedPrs: rem?.mergedPrs ?? 0,
373
+ latestRelease: rem?.latestRelease ?? null,
374
+ linear: linear.get(d.name) ?? null,
375
+ tickets: r?.tickets ?? [],
376
+ worktrees: r?.worktrees ?? 0,
377
+ artifacts: rem?.artifacts ?? 0,
378
+ lastArtifact: rem?.lastArtifact ?? null,
379
+ windowDays,
380
+ repos: [d.repo, ...(d.repos ?? []).map((r2) => r2.slug)].filter(Boolean),
381
+ ...(opts.fleet ? { workspaces: fleetFor(d) } : {}),
382
+ };
383
+ }), null, 2));
384
+ return;
385
+ }
386
+ for (const d of defs) {
387
+ renderCard(d, roll.get(d.name), remote.get(d.name), opts.fleet ? fleetFor(d) : undefined, linear.get(d.name));
388
+ }
389
+ if (fleetSkipped.length > 0)
390
+ process.stdout.write(formatFleetSkippedNote(fleetSkipped));
391
+ });
392
+ // ---- probe (hidden; the peer half of `status --fleet`) ----
393
+ projects
394
+ .command('probe [paths...]', { hidden: true })
395
+ .description('Probe workspace repos (presence, branch, drift, dirtiness) and print JSON. Answers for this machine only.')
396
+ .option('--json', 'Machine-readable output (the only output format)')
397
+ .action((paths) => {
398
+ // Never fans out — the recursion guard env is set by the parent fan-out,
399
+ // and this command has no remote code path either way.
400
+ console.log(JSON.stringify(probeProjectWorkspaces(paths ?? []), null, 2));
401
+ });
402
+ // ---- import ----
403
+ projects
404
+ .command('import')
405
+ .description('Absorb the Factory project registry (~/.agents/factory/projects.json) into YAML definitions.')
406
+ .requiredOption('--from-factory', 'Import from the Factory projects.json registry')
407
+ .option('--force', 'Overwrite existing definitions')
408
+ .action((opts) => {
409
+ const src = factoryProjectsPath();
410
+ let rawText;
411
+ try {
412
+ rawText = fs.readFileSync(src, 'utf8');
413
+ }
414
+ catch {
415
+ console.error(chalk.red(`No Factory registry at ${src}`));
416
+ process.exit(1);
417
+ }
418
+ let rows;
419
+ try {
420
+ rows = JSON.parse(rawText);
421
+ }
422
+ catch {
423
+ console.error(chalk.red(`Factory registry at ${src} is not valid JSON`));
424
+ process.exit(1);
425
+ }
426
+ const list = Array.isArray(rows) ? rows : Array.isArray(rows.projects) ? rows.projects : [];
427
+ let created = 0;
428
+ let skipped = 0;
429
+ for (const raw of list) {
430
+ if (!raw || typeof raw !== 'object')
431
+ continue;
432
+ const o = raw;
433
+ const name = typeof o.name === 'string' ? o.name : undefined;
434
+ if (!name || !isSafeProjectName(name)) {
435
+ skipped++;
436
+ continue;
437
+ }
438
+ if (loadProjectDef(name) && !opts.force) {
439
+ skipped++;
440
+ continue;
441
+ }
442
+ const def = { name };
443
+ if (typeof o.path === 'string')
444
+ def.root = toHomeRelative(o.path);
445
+ if (typeof o.repoSlug === 'string')
446
+ def.repo = o.repoSlug;
447
+ if (typeof o.linearProjectId === 'string')
448
+ def.linear = { projectId: o.linearProjectId };
449
+ writeProjectDef(def);
450
+ created++;
451
+ }
452
+ console.log(chalk.green(`Imported ${created} project${created === 1 ? '' : 's'}${skipped ? chalk.gray(` (${skipped} skipped)`) : ''}`));
453
+ });
454
+ // ---- link ----
455
+ projects
456
+ .command('link <name>')
457
+ .description('Attach an external tracker to a project definition (writes linear.projectId into the YAML).')
458
+ .option('--linear [query]', 'Bind a Linear project by exact name or id; no value auto-suggests from the def name + repo')
459
+ .action((name, opts) => {
460
+ const def = loadProjectDef(name);
461
+ if (!def) {
462
+ console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
463
+ process.exit(1);
464
+ }
465
+ if (opts.linear === undefined) {
466
+ console.error(chalk.red('Nothing to link. Pass a tracker flag, e.g. --linear [query].'));
467
+ process.exit(1);
468
+ }
469
+ // Explicit user command — a missing/unauthenticated `linear` CLI is loud,
470
+ // unlike the best-effort card enrichment.
471
+ let list;
472
+ try {
473
+ list = listLinearProjects();
474
+ }
475
+ catch (e) {
476
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
477
+ process.exit(1);
478
+ }
479
+ const query = typeof opts.linear === 'string' ? opts.linear.trim() : '';
480
+ let pick;
481
+ if (query) {
482
+ pick = pickLinearProject(query, list);
483
+ }
484
+ else {
485
+ // Auto-suggest from the def's own identity — the primary repo slug is
486
+ // the sharpest key, then the def name. Only an exact normalized match
487
+ // writes itself; anything weaker asks the user to disambiguate.
488
+ pick = { kind: 'none' };
489
+ for (const hint of [def.repo, def.name].filter((h) => typeof h === 'string' && h.length > 0)) {
490
+ const d = pickLinearProject(hint, list);
491
+ if (d.kind === 'match') {
492
+ pick = d;
493
+ break;
494
+ }
495
+ if (pick.kind === 'none')
496
+ pick = d;
497
+ }
498
+ }
499
+ if (pick.kind !== 'match') {
500
+ if (pick.kind === 'candidates') {
501
+ console.error(chalk.yellow(`No confident Linear match${query ? ` for "${query}"` : ` for "${def.name}"`}. Candidates:`));
502
+ for (const c of pick.projects)
503
+ console.error(` ${chalk.cyan(c.id)} ${c.name}`);
504
+ }
505
+ else {
506
+ console.error(chalk.yellow(`No Linear project matches${query ? ` "${query}"` : ` "${def.name}"`}. Available:`));
507
+ for (const c of list)
508
+ console.error(` ${chalk.cyan(c.id)} ${c.name}`);
509
+ }
510
+ console.error(chalk.gray(`Re-run with an explicit name or id: agents projects link ${name} --linear "<name-or-id>"`));
511
+ process.exit(1);
512
+ }
513
+ const p = pick.project;
514
+ // Preserve every other field — load, set linear, write back. Keep a
515
+ // previously linked url when the new CLI row carries none.
516
+ if (def.linear?.projectId && def.linear.projectId !== p.id) {
517
+ console.log(chalk.gray(` replacing previous Linear link (${def.linear.projectId})`));
518
+ }
519
+ def.linear = { ...def.linear, projectId: p.id };
520
+ if (p.url)
521
+ def.linear.url = p.url;
522
+ writeProjectDef(def);
523
+ console.log(chalk.green(`${def.name} → Linear project "${p.name}" (${p.id})${p.url ? ` ${p.url}` : ''}`));
524
+ });
525
+ // ---- rm ----
526
+ projects
527
+ .command('rm <name>')
528
+ .alias('remove')
529
+ .description('Delete a project definition. Never touches the repo.')
530
+ .action((name) => {
531
+ if (removeProjectDef(name)) {
532
+ console.log(chalk.green(`Removed project "${name}"`));
533
+ }
534
+ else {
535
+ console.error(chalk.red(`No project named "${name}".`));
536
+ process.exit(1);
537
+ }
538
+ });
539
+ }
@@ -10,6 +10,7 @@ import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
10
10
  import { quoteWin32ExecArg } from '../lib/platform/index.js';
11
11
  import { type SecretsBackend, type SecretsBundle, type SecretsPolicy } from '../lib/secrets/bundles.js';
12
12
  import { type SecretsListFilterOpts } from '../lib/secrets/list-filter.js';
13
+ import { type BundleUsageSummary } from '../lib/secrets/usage-db.js';
13
14
  /** Read all available data from stdin synchronously, trimmed. */
14
15
  /**
15
16
  * Read the raw `.env` text for `import --from <path|->`. A `-` reads the .env
@@ -125,6 +126,22 @@ export declare function formatHoldWindow(ms: number): string;
125
126
  * shared by the POLICY column, the `--held` filter, and the JSON payload, so the
126
127
  * three can never disagree about the same bundle. */
127
128
  export declare function liveHold(expiresAt: number | undefined, now?: number): number | null;
129
+ /**
130
+ * Compact one-line usage summary for `secrets view` — each recorded event with
131
+ * its count and how long ago it last happened ("accessed 42× (last 2h ago) ·
132
+ * exported 3× (last 1d ago)"). Empty string when nothing has been recorded yet.
133
+ * Pure — unit-tested.
134
+ */
135
+ export declare function formatUsageLine(summary: BundleUsageSummary | undefined): string;
136
+ /**
137
+ * The held-state line for `secrets view`: whether the secrets-agent currently
138
+ * holds this bundle (so reads are prompt-free) and for how long, or that Touch
139
+ * ID is required on the next read. Only meaningful for keychain bundles on macOS
140
+ * — file/vault backends have no broker to hold, and a `never`-policy bundle is
141
+ * always readable — so both return an empty line and the caller skips it.
142
+ * `heldExpiresAt` is the max expiry across every held scope.
143
+ */
144
+ export declare function renderViewStatusLine(b: SecretsBundle, heldExpiresAt: number | null): string;
128
145
  /**
129
146
  * The EXPIRING cell. Counts keys needing attention — already expired plus due
130
147
  * within 30 days — and colours by the worst of the two: red once anything has