@phnx-labs/agents-cli 1.21.1 → 1.21.3
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 +206 -0
- package/README.md +1 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/computer-actions.d.ts +4 -0
- package/dist/commands/computer-actions.js +34 -0
- package/dist/commands/computer.js +4 -2
- package/dist/commands/doctor.js +5 -2
- package/dist/commands/feed.js +28 -19
- package/dist/commands/hooks.js +9 -45
- package/dist/commands/menubar.js +24 -24
- package/dist/commands/message.js +23 -3
- package/dist/commands/perf.d.ts +29 -0
- package/dist/commands/perf.js +90 -23
- package/dist/commands/projects.d.ts +21 -0
- package/dist/commands/projects.js +169 -23
- package/dist/commands/routines.js +46 -1
- package/dist/commands/sessions-picker.js +17 -2
- package/dist/commands/sessions.d.ts +5 -0
- package/dist/commands/sessions.js +14 -0
- package/dist/commands/ssh.js +69 -0
- package/dist/commands/trends.d.ts +2 -0
- package/dist/commands/trends.js +158 -0
- package/dist/commands/usage.d.ts +4 -4
- package/dist/commands/view.d.ts +6 -0
- package/dist/commands/view.js +90 -45
- package/dist/index.js +22 -1
- package/dist/lib/agents.js +2 -2
- package/dist/lib/analytics/dashboard.d.ts +11 -0
- package/dist/lib/analytics/dashboard.js +31 -0
- package/dist/lib/analytics/recipes.d.ts +32 -0
- package/dist/lib/analytics/recipes.js +316 -0
- package/dist/lib/analytics/usage-db.d.ts +84 -0
- package/dist/lib/analytics/usage-db.js +301 -0
- package/dist/lib/browser/service.js +31 -0
- package/dist/lib/cli-resources.d.ts +20 -0
- package/dist/lib/cli-resources.js +48 -1
- package/dist/lib/computer/dispatch.d.ts +3 -1
- package/dist/lib/computer/dispatch.js +10 -2
- package/dist/lib/daemon.js +51 -14
- package/dist/lib/devices/health-report.d.ts +5 -0
- package/dist/lib/devices/health-report.js +3 -0
- package/dist/lib/event-stream.d.ts +2 -0
- package/dist/lib/event-stream.js +3 -0
- package/dist/lib/events.d.ts +3 -1
- package/dist/lib/events.js +4 -2
- package/dist/lib/feed-broadcast.d.ts +52 -7
- package/dist/lib/feed-broadcast.js +125 -18
- package/dist/lib/fleet-cache.d.ts +37 -0
- package/dist/lib/fleet-cache.js +40 -0
- package/dist/lib/fleet-status.d.ts +53 -0
- package/dist/lib/fleet-status.js +120 -0
- package/dist/lib/friction-heuristics.d.ts +32 -0
- package/dist/lib/friction-heuristics.js +47 -0
- package/dist/lib/git.d.ts +14 -0
- package/dist/lib/git.js +36 -0
- package/dist/lib/hooks/cache.js +28 -6
- package/dist/lib/hooks/profile.d.ts +8 -0
- package/dist/lib/hooks/profile.js +15 -18
- package/dist/lib/hooks.js +72 -17
- package/dist/lib/linear-cache.d.ts +63 -0
- package/dist/lib/linear-cache.js +146 -0
- package/dist/lib/linear-project-counts.d.ts +35 -5
- package/dist/lib/linear-project-counts.js +61 -16
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +7 -0
- package/dist/lib/menubar/install-menubar.js +36 -6
- package/dist/lib/percentile.d.ts +12 -0
- package/dist/lib/percentile.js +24 -0
- package/dist/lib/perf/db.d.ts +7 -3
- package/dist/lib/perf/db.js +37 -19
- package/dist/lib/perf/types.d.ts +10 -0
- package/dist/lib/plugins.js +12 -1
- package/dist/lib/project-doctor.d.ts +36 -0
- package/dist/lib/project-doctor.js +45 -0
- package/dist/lib/project-import.d.ts +11 -1
- package/dist/lib/project-import.js +17 -3
- package/dist/lib/project-status.d.ts +25 -5
- package/dist/lib/project-status.js +48 -6
- package/dist/lib/resources.d.ts +16 -0
- package/dist/lib/resources.js +25 -14
- package/dist/lib/rotate.d.ts +27 -0
- package/dist/lib/rotate.js +44 -17
- package/dist/lib/routines.d.ts +16 -0
- package/dist/lib/routines.js +26 -0
- package/dist/lib/runner.js +41 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/usage-db.d.ts +3 -63
- package/dist/lib/secrets/usage-db.js +46 -186
- package/dist/lib/session/db.d.ts +17 -1
- package/dist/lib/session/db.js +240 -6
- package/dist/lib/session/discover.d.ts +31 -0
- package/dist/lib/session/discover.js +50 -1
- package/dist/lib/session/highlights.d.ts +24 -4
- package/dist/lib/session/highlights.js +52 -7
- package/dist/lib/session/parse.js +19 -3
- package/dist/lib/session/prompt.d.ts +19 -0
- package/dist/lib/session/prompt.js +43 -0
- package/dist/lib/session/types.d.ts +43 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +31 -3
- package/dist/lib/state.js +53 -10
- package/dist/lib/types.d.ts +22 -4
- package/dist/lib/usage-refresh.d.ts +106 -0
- package/dist/lib/usage-refresh.js +238 -0
- package/dist/lib/usage.d.ts +152 -17
- package/dist/lib/usage.js +393 -79
- package/package.json +1 -1
|
@@ -25,9 +25,10 @@ import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
|
|
|
25
25
|
import { factoryProjectsPath } from '../lib/auto-dispatch.js';
|
|
26
26
|
import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, } from '../lib/project-probe.js';
|
|
27
27
|
import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
|
|
28
|
-
import { rollupSessionsByProject,
|
|
28
|
+
import { rollupSessionsByProject, liveDeadSplit, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
|
|
29
29
|
import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
|
|
30
30
|
import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
|
|
31
|
+
import { checkRepoSlug } from '../lib/project-doctor.js';
|
|
31
32
|
import { buildFactoryImportCandidates, buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
|
|
32
33
|
/** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
|
|
33
34
|
export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
|
|
@@ -56,10 +57,24 @@ function parseContextFlag(raw) {
|
|
|
56
57
|
return { path: raw.trim(), purpose: '' };
|
|
57
58
|
return { path: raw.slice(0, i).trim(), purpose: raw.slice(i + 1).trim() };
|
|
58
59
|
}
|
|
59
|
-
/**
|
|
60
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Best-effort `owner/repo` from a repo's origin remote.
|
|
62
|
+
*
|
|
63
|
+
* `stderr: 'ignore'` is load-bearing, not tidiness. A checkout with no origin
|
|
64
|
+
* makes git print `error: No such remote 'origin'` on ITS stderr, which is the
|
|
65
|
+
* terminal's — the catch below never sees it. That was invisible while this was
|
|
66
|
+
* called once from `add` inside a real repo, and became noise the moment
|
|
67
|
+
* `import --from-factory` started calling it per row: importing 12 registry
|
|
68
|
+
* rows printed two raw git errors between the progress lines. Absence of a
|
|
69
|
+
* remote is an expected answer here (`undefined`), not something to report.
|
|
70
|
+
*/
|
|
71
|
+
export function originSlug(cwd) {
|
|
61
72
|
try {
|
|
62
|
-
const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
73
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
74
|
+
cwd,
|
|
75
|
+
encoding: 'utf8',
|
|
76
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
77
|
+
}).trim();
|
|
63
78
|
return parseOwnerRepoFromRemote(url) ?? undefined;
|
|
64
79
|
}
|
|
65
80
|
catch {
|
|
@@ -94,7 +109,9 @@ function runFactoryImport(existing, opts) {
|
|
|
94
109
|
: Array.isArray(parsed?.projects)
|
|
95
110
|
? parsed.projects
|
|
96
111
|
: [];
|
|
97
|
-
|
|
112
|
+
// The checkout's real `origin` overrides the registry's path-derived slug —
|
|
113
|
+
// see buildFactoryImportCandidates for why the path guess is not trustworthy.
|
|
114
|
+
return buildFactoryImportCandidates(rows, existing, opts, (root) => originSlug(expandLocalHome(root)));
|
|
98
115
|
}
|
|
99
116
|
/**
|
|
100
117
|
* List the workspace's Linear projects and plan their import, binding each to a
|
|
@@ -183,6 +200,40 @@ export function formatMilestoneDue(targetDate, nowMs) {
|
|
|
183
200
|
});
|
|
184
201
|
return `due ${label}`;
|
|
185
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* The milestone block. `status` prints one line — the next checkpoint — because
|
|
205
|
+
* a roll-up across projects has to stay scannable. `view` prints every declared
|
|
206
|
+
* milestone, because "how many are there and when are they due" is the shape of
|
|
207
|
+
* the plan and the reason to open one project.
|
|
208
|
+
*
|
|
209
|
+
* Pure (chalk only) so the layout is testable without a Linear account.
|
|
210
|
+
*/
|
|
211
|
+
export function formatMilestoneLines(milestones, next, nowMs, limit) {
|
|
212
|
+
if (milestones.length === 0) {
|
|
213
|
+
// `next` without a list only happens on a cached answer written before the
|
|
214
|
+
// list existed; render what we have rather than dropping the row.
|
|
215
|
+
return next ? [` ${chalk.dim('next')} ${formatNextMilestone(next, nowMs)}`] : [];
|
|
216
|
+
}
|
|
217
|
+
// The next milestone leads, always. Linear can flag a LATER-dated milestone
|
|
218
|
+
// as next, and `milestones` is date-ordered — so slicing the front would show
|
|
219
|
+
// an earlier one and hide the actual next behind "+N more", which is the one
|
|
220
|
+
// thing this row exists to say. Identity is name+targetDate: two milestones
|
|
221
|
+
// can share a name, and matching on name alone labelled the wrong row.
|
|
222
|
+
const key = (m) => `${m.name}${m.targetDate ?? ''}`;
|
|
223
|
+
const lead = next ? milestones.filter((m) => key(m) === key(next)).slice(0, 1) : [];
|
|
224
|
+
const others = next ? milestones.filter((m) => key(m) !== key(next)) : milestones;
|
|
225
|
+
const ordered = [...lead, ...others];
|
|
226
|
+
const shown = ordered.slice(0, Math.max(1, limit));
|
|
227
|
+
const out = shown.map((m, i) => {
|
|
228
|
+
const label = i === 0 && lead.length > 0 ? 'next' : i === 0 ? 'plan' : '';
|
|
229
|
+
return ` ${chalk.dim(label.padEnd(4))} ${formatNextMilestone(m, nowMs)}`;
|
|
230
|
+
});
|
|
231
|
+
const rest = milestones.length - shown.length;
|
|
232
|
+
if (rest > 0) {
|
|
233
|
+
out.push(` ${' '.repeat(4)} ${chalk.dim(`+${rest} more milestone${rest === 1 ? '' : 's'} — agents projects view <name>`)}`);
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
186
237
|
/** The `next` card line: what this project is due to hit, and how far along it is. */
|
|
187
238
|
export function formatNextMilestone(ms, nowMs) {
|
|
188
239
|
const parts = [chalk.bold(ms.name)];
|
|
@@ -206,25 +257,39 @@ function statusBar(r) {
|
|
|
206
257
|
push(r.byStatus.input_required, 'need-input', chalk.yellow);
|
|
207
258
|
push(r.byStatus.queued, 'queued', chalk.gray);
|
|
208
259
|
const shown = (r.byStatus.running ?? 0) + (r.byStatus.idle ?? 0) + (r.byStatus.input_required ?? 0) + (r.byStatus.queued ?? 0);
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
260
|
+
// The remainder is the LIVE sessions in a state without its own chip
|
|
261
|
+
// (orphaned, unknown). Dead ones are on their own row now, so counting them
|
|
262
|
+
// here made the line disagree with the headline — `19 live` beside
|
|
263
|
+
// `+24 other`, which invites the reader to trust neither.
|
|
264
|
+
const live = liveDeadSplit(r.byStatus).live;
|
|
265
|
+
if (live > shown)
|
|
266
|
+
parts.push(chalk.gray(`+${live - shown} other`));
|
|
213
267
|
return parts.join(' · ') || chalk.gray('no live agents');
|
|
214
268
|
}
|
|
215
|
-
function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
269
|
+
function renderCard(def, r, remote, fleet, linear, nowMs = Date.now(),
|
|
270
|
+
/** How many milestones to print. `status` shows the next one; `view` shows all. */
|
|
271
|
+
milestoneLimit = 1) {
|
|
272
|
+
// The headline counts LIVE agents. It used to be every matched session, which
|
|
273
|
+
// read `39 agents` on a project where 19 had crashed. `planPct` used to sit
|
|
274
|
+
// here too and is gone: it summed each session's latest checklist snapshot,
|
|
275
|
+
// so one agent opening a fresh 40-item plan rendered the whole project `0%`.
|
|
276
|
+
const split = r ? liveDeadSplit(r.byStatus) : { live: 0, dead: 0, deadByStatus: [] };
|
|
277
|
+
console.log(`${chalk.bold(def.name)} ${chalk.dim('·')} ${chalk.bold(`${split.live} live`)}`);
|
|
220
278
|
if (def.description)
|
|
221
279
|
console.log(` ${chalk.dim(def.description)}`);
|
|
222
280
|
console.log(` ${chalk.dim('live')} ${r ? statusBar(r) : chalk.gray('no live agents')}`);
|
|
281
|
+
if (split.dead > 0) {
|
|
282
|
+
// Wreckage is worth a number of its own — 19 crashed sessions is a thing to
|
|
283
|
+
// go fix, not a throughput signal to fold into the headline.
|
|
284
|
+
const detail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
|
|
285
|
+
console.log(` ${chalk.dim('dead')} ${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${detail})`)}`);
|
|
286
|
+
}
|
|
223
287
|
if (r && r.members.length)
|
|
224
288
|
console.log(` ${chalk.dim('agents')} ${formatProjectMembers(r.members)}`);
|
|
225
289
|
const ships = [];
|
|
226
|
-
if (remote?.mergedPrs)
|
|
227
|
-
ships.push(chalk.green(`${remote.mergedPrs} merged (${remote.windowDays}d)`));
|
|
290
|
+
if (remote?.mergedPrs) {
|
|
291
|
+
ships.push(chalk.green(`${remote.mergedPrs}${remote.mergedPrsTruncated ? '+' : ''} merged (${remote.windowDays}d)`));
|
|
292
|
+
}
|
|
228
293
|
if (r?.openPrs.length)
|
|
229
294
|
ships.push(`${r.openPrs.length} open PR${r.openPrs.length === 1 ? '' : 's'}`);
|
|
230
295
|
if (r?.worktrees)
|
|
@@ -236,8 +301,8 @@ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
|
|
|
236
301
|
if (linear) {
|
|
237
302
|
console.log(` ${chalk.dim('linear')} ${linear.done}/${linear.total}${linear.truncated ? '+' : ''} done · ${linear.inProgress} in progress`);
|
|
238
303
|
}
|
|
239
|
-
|
|
240
|
-
console.log(
|
|
304
|
+
for (const line of formatMilestoneLines(linear?.milestones ?? [], linear?.nextMilestone, nowMs, milestoneLimit)) {
|
|
305
|
+
console.log(line);
|
|
241
306
|
}
|
|
242
307
|
if (r && r.tickets.length) {
|
|
243
308
|
console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
|
|
@@ -258,6 +323,14 @@ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
|
|
|
258
323
|
const repos = [def.repo, ...(def.repos ?? []).map((x) => x.slug)].filter(Boolean);
|
|
259
324
|
if (repos.length)
|
|
260
325
|
console.log(` ${chalk.dim('repos')} ${[...new Set(repos)].join(' · ')}`);
|
|
326
|
+
// A def whose `repo` disagrees with the checkout's origin reads PR and release
|
|
327
|
+
// counts from a DIFFERENT repository — both slugs resolve, so nothing errors
|
|
328
|
+
// and the wrong numbers look right. Say it here, with the fix attached.
|
|
329
|
+
const mismatch = def.root ? checkRepoSlug(def, originSlug(expandLocalHome(def.root))) : undefined;
|
|
330
|
+
if (mismatch) {
|
|
331
|
+
console.log(` ${chalk.yellow('!')} ${chalk.yellow(mismatch.message)}`);
|
|
332
|
+
console.log(` ${' '.repeat(8)}${chalk.gray(mismatch.remediation)}`);
|
|
333
|
+
}
|
|
261
334
|
if (def.contexts?.length) {
|
|
262
335
|
console.log(` ${chalk.dim('context')} ${def.contexts.map((c) => c.path).join(' · ')}`);
|
|
263
336
|
}
|
|
@@ -381,17 +454,24 @@ export function registerProjectsCommands(program) {
|
|
|
381
454
|
});
|
|
382
455
|
// ---- show ----
|
|
383
456
|
projects
|
|
384
|
-
.command('
|
|
385
|
-
.
|
|
457
|
+
.command('view <name>')
|
|
458
|
+
.alias('show')
|
|
459
|
+
.description('Show a project definition, resolved paths, repos, contexts, links, and milestones.')
|
|
386
460
|
.option('--json', 'Machine-readable output')
|
|
387
|
-
.
|
|
461
|
+
.option('--no-remote', 'Skip the Linear lookup; definition only')
|
|
462
|
+
.action(async (name, opts) => {
|
|
388
463
|
const def = loadProjectDef(name);
|
|
389
464
|
if (!def) {
|
|
390
465
|
console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
|
|
391
466
|
process.exit(1);
|
|
392
467
|
}
|
|
468
|
+
// Every milestone the project declares, not just the next one — the whole
|
|
469
|
+
// point of opening ONE project is to see the shape of its plan.
|
|
470
|
+
const counts = opts.remote !== false && def.linear?.projectId
|
|
471
|
+
? await fetchLinearProjectCounts(def.linear.projectId)
|
|
472
|
+
: undefined;
|
|
393
473
|
if (opts.json) {
|
|
394
|
-
console.log(JSON.stringify(def, null, 2));
|
|
474
|
+
console.log(JSON.stringify({ ...def, linear: { ...def.linear, ...(counts ?? {}) } }, null, 2));
|
|
395
475
|
return;
|
|
396
476
|
}
|
|
397
477
|
console.log(chalk.bold(def.name) + (def.description ? chalk.dim(` — ${def.description}`) : ''));
|
|
@@ -410,6 +490,24 @@ export function registerProjectsCommands(program) {
|
|
|
410
490
|
console.log(` linear ${def.linear.url ?? def.linear.projectId}`);
|
|
411
491
|
for (const d of def.docs ?? [])
|
|
412
492
|
console.log(` doc ${d}`);
|
|
493
|
+
if (counts) {
|
|
494
|
+
const staleNote = counts.stale ? chalk.dim(' (cached)') : '';
|
|
495
|
+
console.log(` issues ${counts.done}/${counts.total}${counts.truncated ? '+' : ''} done · ${counts.inProgress} in progress${staleNote}`);
|
|
496
|
+
}
|
|
497
|
+
const ms = counts?.milestones ?? [];
|
|
498
|
+
if (ms.length) {
|
|
499
|
+
console.log(` ${chalk.bold('milestones')}`);
|
|
500
|
+
for (const m of ms) {
|
|
501
|
+
const mark = m.name === counts?.nextMilestone?.name ? chalk.cyan('next') : '';
|
|
502
|
+
console.log(` ${mark.padEnd(6)} ${formatNextMilestone(m, Date.now())}`);
|
|
503
|
+
}
|
|
504
|
+
// A milestone nothing is filed against cannot report progress. Say that
|
|
505
|
+
// once, plainly, rather than printing a row of silent 0%s.
|
|
506
|
+
const unfiled = ms.filter((m) => m.total === 0).length;
|
|
507
|
+
if (unfiled === ms.length) {
|
|
508
|
+
console.log(` ${chalk.yellow('!')} ${chalk.yellow(`no issues are assigned to any milestone — progress against them cannot be measured`)}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
413
511
|
console.log(chalk.gray(` ${projectDefPath(name)}`));
|
|
414
512
|
});
|
|
415
513
|
// ---- edit ----
|
|
@@ -514,7 +612,8 @@ export function registerProjectsCommands(program) {
|
|
|
514
612
|
byStatus: r?.byStatus ?? {},
|
|
515
613
|
members: r?.members ?? [],
|
|
516
614
|
plan: r?.plan ?? { done: 0, total: 0 },
|
|
517
|
-
|
|
615
|
+
live: r ? liveDeadSplit(r.byStatus).live : 0,
|
|
616
|
+
dead: r ? liveDeadSplit(r.byStatus).dead : 0,
|
|
518
617
|
openPrs: r?.openPrs ?? [],
|
|
519
618
|
mergedPrs: rem?.mergedPrs ?? 0,
|
|
520
619
|
latestRelease: rem?.latestRelease ?? null,
|
|
@@ -577,6 +676,53 @@ export function registerProjectsCommands(program) {
|
|
|
577
676
|
console.log(chalk.gray(' (widen with --min-confidence medium or --all)'));
|
|
578
677
|
}
|
|
579
678
|
});
|
|
679
|
+
// ---- set ----
|
|
680
|
+
projects
|
|
681
|
+
.command('set <name>')
|
|
682
|
+
.description('Change one field on a project definition, preserving everything else.')
|
|
683
|
+
.option('--repo <owner/repo>', 'Primary GitHub slug')
|
|
684
|
+
.option('--root <path>', 'Repo / monorepo root')
|
|
685
|
+
.option('--path <subdir>', 'Default cwd for agents (a monorepo subdir)')
|
|
686
|
+
.option('--description <text>', 'One-line description shown on the card')
|
|
687
|
+
.action((name, opts) => {
|
|
688
|
+
const def = loadProjectDef(name);
|
|
689
|
+
if (!def) {
|
|
690
|
+
console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
|
|
691
|
+
process.exit(1);
|
|
692
|
+
}
|
|
693
|
+
const fields = ['repo', 'root', 'path', 'description'].filter((k) => opts[k] !== undefined);
|
|
694
|
+
if (fields.length === 0) {
|
|
695
|
+
console.error(chalk.red('Nothing to set. Pass a field, e.g. --repo <owner/repo>.'));
|
|
696
|
+
process.exit(1);
|
|
697
|
+
}
|
|
698
|
+
// Load, mutate the named fields, write back — the `link` pattern. NEVER
|
|
699
|
+
// the `add --force` pattern, which rebuilds the def from flags alone and
|
|
700
|
+
// silently drops linear/contexts/integrations that were not re-passed.
|
|
701
|
+
if (opts.repo !== undefined)
|
|
702
|
+
def.repo = opts.repo;
|
|
703
|
+
if (opts.root !== undefined)
|
|
704
|
+
def.root = opts.root;
|
|
705
|
+
if (opts.description !== undefined)
|
|
706
|
+
def.description = opts.description;
|
|
707
|
+
if (opts.path !== undefined) {
|
|
708
|
+
// `--path` is a subdir OF the root, so without one there is nothing to
|
|
709
|
+
// hang it off. A def imported with `--from-linear` that found no local
|
|
710
|
+
// checkout carries name + linear and no root — joining against '' there
|
|
711
|
+
// would silently write `/apps/cli`, an absolute path at the filesystem
|
|
712
|
+
// root, and the def would resolve somewhere that does not exist.
|
|
713
|
+
const base = (opts.root ?? def.root ?? '').replace(/\/$/, '');
|
|
714
|
+
if (!base) {
|
|
715
|
+
console.error(chalk.red(`"${def.name}" has no root, so --path has nothing to resolve against.`));
|
|
716
|
+
console.error(chalk.gray(` Set one first: agents projects set ${def.name} --root <path> --path ${opts.path}`));
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
def.defaultPath = `${base}/${opts.path.replace(/^\//, '')}`;
|
|
720
|
+
}
|
|
721
|
+
writeProjectDef(def);
|
|
722
|
+
console.log(chalk.green(`Updated ${def.name}`));
|
|
723
|
+
for (const f of fields)
|
|
724
|
+
console.log(chalk.gray(` ${f} ${f === 'path' ? def.defaultPath : def[f]}`));
|
|
725
|
+
});
|
|
580
726
|
// ---- link ----
|
|
581
727
|
projects
|
|
582
728
|
.command('link <name>')
|
|
@@ -14,7 +14,7 @@ import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemo
|
|
|
14
14
|
import { assertSchedulerEnabled } from '../lib/device-config.js';
|
|
15
15
|
import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
|
|
16
16
|
import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
|
|
17
|
-
import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
|
|
17
|
+
import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, routineStats, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
|
|
18
18
|
import { discoverProjectRoutinesAt, enableProjectRoutines, disableProjectRoutines, syncProjectRoutines, syncAllProjectRoutines, listEnabledProjectRoots, resolveProjectRoot, displayProjectPath, listProjectRoutineFiles, } from '../lib/routines-project.js';
|
|
19
19
|
import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js';
|
|
20
20
|
import { getRoutinesDir } from '../lib/state.js';
|
|
@@ -359,6 +359,7 @@ function runMetaJson(run) {
|
|
|
359
359
|
completedAt: run.completedAt,
|
|
360
360
|
exitCode: run.exitCode,
|
|
361
361
|
errorMessage: run.errorMessage ?? null,
|
|
362
|
+
duration: run.duration ?? null,
|
|
362
363
|
};
|
|
363
364
|
}
|
|
364
365
|
export function buildRunsJson(runs) {
|
|
@@ -1034,6 +1035,50 @@ export function registerRoutinesCommands(program) {
|
|
|
1034
1035
|
console.log(` ${run.runId} ${status} ${run.startedAt}`);
|
|
1035
1036
|
}
|
|
1036
1037
|
});
|
|
1038
|
+
routinesCmd
|
|
1039
|
+
.command('stats [name]')
|
|
1040
|
+
.description('Duration + outcome rollup per job: run count, failed, missed, avg/p50/p95 duration')
|
|
1041
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
1042
|
+
.action(async (name, options) => {
|
|
1043
|
+
// No name: summarize every job (like `routines list`). A name narrows to
|
|
1044
|
+
// one job's rollup — no interactive picker, since a bare `stats` already
|
|
1045
|
+
// has a useful all-jobs default.
|
|
1046
|
+
if (!name) {
|
|
1047
|
+
const jobs = listAllJobs();
|
|
1048
|
+
const rows = jobs.map((j) => ({ name: j.name, ...routineStats(j.name) }));
|
|
1049
|
+
if (options.json) {
|
|
1050
|
+
writeJson({ jobs: rows });
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
if (rows.length === 0) {
|
|
1054
|
+
console.log(chalk.gray('No jobs configured'));
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
console.log(chalk.bold('Routine stats\n'));
|
|
1058
|
+
const pad = (s, w) => (s.length >= w ? s.slice(0, w) : s + ' '.repeat(w - s.length));
|
|
1059
|
+
console.log(chalk.gray(` ${pad('JOB', 28)} ${pad('N', 5)} ${pad('FAILED', 7)} ${pad('MISSED', 7)} ${pad('AVG', 7)} ${pad('P50', 7)} P95`));
|
|
1060
|
+
for (const r of rows) {
|
|
1061
|
+
console.log(` ${pad(r.name, 28)} ${pad(String(r.count), 5)} ${pad(String(r.failed), 7)} ${pad(String(r.missed), 7)} ${pad(`${r.avgMs}ms`, 7)} ${pad(`${r.p50}ms`, 7)} ${r.p95}ms`);
|
|
1062
|
+
}
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const stats = routineStats(name);
|
|
1066
|
+
if (options.json) {
|
|
1067
|
+
writeJson({ jobId: name, name, ...stats });
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (stats.count === 0) {
|
|
1071
|
+
console.log(chalk.yellow(`No runs found for job '${name}'`));
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
console.log(chalk.bold(`Stats: ${name}\n`));
|
|
1075
|
+
console.log(` Runs: ${stats.count}`);
|
|
1076
|
+
console.log(` Failed: ${stats.failed}`);
|
|
1077
|
+
console.log(` Missed: ${stats.missed}`);
|
|
1078
|
+
console.log(` Avg: ${stats.avgMs}ms`);
|
|
1079
|
+
console.log(` P50: ${stats.p50}ms`);
|
|
1080
|
+
console.log(` P95: ${stats.p95}ms`);
|
|
1081
|
+
});
|
|
1037
1082
|
routinesCmd
|
|
1038
1083
|
.command('run [name]')
|
|
1039
1084
|
.description('Execute a routine right now in the foreground. Ignores the schedule; useful for testing before enabling.')
|
|
@@ -374,6 +374,13 @@ function formatCompactPreview(events, session) {
|
|
|
374
374
|
let latestTodos;
|
|
375
375
|
let subAgentCount = 0;
|
|
376
376
|
const toolTags = new Set();
|
|
377
|
+
// usedBrowser/usedComputer are computed at scan time from a sessionId-scoped
|
|
378
|
+
// events-log read (session/db.ts detectToolUsage), NOT a transcript regex —
|
|
379
|
+
// undefined means a legacy row this scanner hasn't computed the field for
|
|
380
|
+
// yet, so only THEN does classifySessionTool's transcript-derived guess run
|
|
381
|
+
// below (mirrors directoriesTouched's prefer-persisted/fall-back-to-derived
|
|
382
|
+
// pattern for recentDirectoriesTouched).
|
|
383
|
+
const knownToolUsage = session.usedBrowser !== undefined;
|
|
377
384
|
for (const event of events) {
|
|
378
385
|
if (event.type === 'message') {
|
|
379
386
|
if (event.role === 'user' && !firstUser && event.content) {
|
|
@@ -388,8 +395,10 @@ function formatCompactPreview(events, session) {
|
|
|
388
395
|
else if (event.type === 'tool_use' && !event._local) {
|
|
389
396
|
const tool = event.tool || '';
|
|
390
397
|
const command = event.command || '';
|
|
391
|
-
|
|
392
|
-
|
|
398
|
+
if (!knownToolUsage) {
|
|
399
|
+
for (const tag of classifySessionTool(tool, command))
|
|
400
|
+
toolTags.add(tag);
|
|
401
|
+
}
|
|
393
402
|
if (isSubAgentTool(tool, command))
|
|
394
403
|
subAgentCount++;
|
|
395
404
|
const p = event.path || event.args?.file_path || event.args?.path || '';
|
|
@@ -412,6 +421,12 @@ function formatCompactPreview(events, session) {
|
|
|
412
421
|
toolCalls++;
|
|
413
422
|
}
|
|
414
423
|
}
|
|
424
|
+
// Persisted field wins when computed — it comes from real browser.navigate/
|
|
425
|
+
// browser.screenshot/computer.action events, not a fuzzy tool-name regex.
|
|
426
|
+
if (session.usedBrowser)
|
|
427
|
+
toolTags.add('browser');
|
|
428
|
+
if (session.usedComputer)
|
|
429
|
+
toolTags.add('computer');
|
|
415
430
|
// Prefer the transcript-derived list when we just re-parsed the file (freshest
|
|
416
431
|
// checklist write); fall back to SessionMeta.todos for rows where the scan/
|
|
417
432
|
// fan-out attached progress but the event stream has no TodoWrite yet.
|
|
@@ -70,6 +70,11 @@ interface SessionsOptions extends SessionFilterOptions {
|
|
|
70
70
|
printCmd?: boolean;
|
|
71
71
|
/** Print a compact preview of the matched session and exit (no pager). */
|
|
72
72
|
preview?: boolean;
|
|
73
|
+
/** Only sessions that invoked this skill (#12) — matches a bare name or a
|
|
74
|
+
* namespaced plugin skill's short name (`--skill design` finds `rush:design`). */
|
|
75
|
+
skill?: string;
|
|
76
|
+
/** Only sessions that used a skill/command owned by this plugin (#12). */
|
|
77
|
+
plugin?: string;
|
|
73
78
|
}
|
|
74
79
|
/**
|
|
75
80
|
* Strip terminal/harness noise from a preview so the column stays a single line
|
|
@@ -1172,6 +1172,12 @@ export function hasNoBrowserDisqualifyingFlags(options, query) {
|
|
|
1172
1172
|
!options.markdown &&
|
|
1173
1173
|
!options.until &&
|
|
1174
1174
|
!options.project &&
|
|
1175
|
+
// The interactive browser picker is a fuzzy-search TUI over the discovered
|
|
1176
|
+
// pool, not a SQL-filtered listing — it cannot represent a skill/plugin
|
|
1177
|
+
// scope. Falling through to it here would silently drop the filter and
|
|
1178
|
+
// show the unfiltered pool instead (same reasoning as --project/--sort).
|
|
1179
|
+
!options.skill &&
|
|
1180
|
+
!options.plugin &&
|
|
1175
1181
|
!options.sort &&
|
|
1176
1182
|
!options.artifacts &&
|
|
1177
1183
|
options.artifact === undefined &&
|
|
@@ -1202,6 +1208,10 @@ function canonicalSessionsCommand(query, options) {
|
|
|
1202
1208
|
a.push('--device', h);
|
|
1203
1209
|
if (options.project)
|
|
1204
1210
|
a.push('--project', options.project);
|
|
1211
|
+
if (options.skill)
|
|
1212
|
+
a.push('--skill', options.skill);
|
|
1213
|
+
if (options.plugin)
|
|
1214
|
+
a.push('--plugin', options.plugin);
|
|
1205
1215
|
if (options.all)
|
|
1206
1216
|
a.push('--all');
|
|
1207
1217
|
if (options.since)
|
|
@@ -1557,6 +1567,8 @@ limitSource) {
|
|
|
1557
1567
|
until: options.until,
|
|
1558
1568
|
sortBy,
|
|
1559
1569
|
origin: options.routine ? 'routine' : undefined,
|
|
1570
|
+
skill: options.skill,
|
|
1571
|
+
plugin: options.plugin,
|
|
1560
1572
|
};
|
|
1561
1573
|
let hiddenUnmanaged = 0;
|
|
1562
1574
|
let sessions = await discoverSessions({
|
|
@@ -3306,6 +3318,8 @@ export function registerSessionsCommands(program) {
|
|
|
3306
3318
|
.option('--in-team <name>', "Only this team: the session that spawned it plus (with --teams) its teammates. Spans every directory and all time, since a team's worktrees and history sit outside the default window.")
|
|
3307
3319
|
.option('--routine', 'Show only sessions archived from routine runs')
|
|
3308
3320
|
.option('-p, --project <name>', 'Filter by project name (searches across all directories)')
|
|
3321
|
+
.option('--skill <name>', 'Only sessions that invoked this skill (matches a bare name or a namespaced plugin skill\'s short name, e.g. --skill design finds rush:design)')
|
|
3322
|
+
.option('--plugin <name>', 'Only sessions that used a skill/command owned by this plugin')
|
|
3309
3323
|
.option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
|
|
3310
3324
|
.option('--until <time>', 'Only sessions older than this (ISO timestamp)')
|
|
3311
3325
|
.option('-n, --limit <n>', 'Maximum number of sessions to return', DEFAULT_LIMIT)
|
package/dist/commands/ssh.js
CHANGED
|
@@ -332,6 +332,20 @@ async function localHealthRow(self, stats) {
|
|
|
332
332
|
inventory: await collectLocalFleetInventory(process.cwd()),
|
|
333
333
|
};
|
|
334
334
|
}
|
|
335
|
+
/** SSH into a host and read its already-computed fleet-status row (a cheap
|
|
336
|
+
* `fleet status --local --json` on the peer — NOT a fresh remote resource probe;
|
|
337
|
+
* the peer's daemon keeps that row warm). Bounded + reaped via sshExecAsync's
|
|
338
|
+
* timeout (RUSH-2114). */
|
|
339
|
+
async function probeRemoteFleetStatus(target) {
|
|
340
|
+
const isWin = /^win/i.test((target.platform ?? '').trim());
|
|
341
|
+
const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
|
|
342
|
+
const cmd = buildRemoteAgentsInvocation(['devices', 'status', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
|
|
343
|
+
const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true });
|
|
344
|
+
if (res.code !== 0) {
|
|
345
|
+
throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
|
|
346
|
+
}
|
|
347
|
+
return JSON.parse(res.stdout);
|
|
348
|
+
}
|
|
335
349
|
async function probeRemoteHealth(target) {
|
|
336
350
|
const isWin = /^win/i.test((target.platform ?? '').trim());
|
|
337
351
|
const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
|
|
@@ -363,6 +377,19 @@ async function runFleetStatus(opts) {
|
|
|
363
377
|
const reg = await loadDevices();
|
|
364
378
|
const self = machineId();
|
|
365
379
|
const forceRefresh = Boolean(opts.refresh || opts.live);
|
|
380
|
+
// `--local`: the publish endpoint the read-union reads over ssh. Probe THIS
|
|
381
|
+
// host only (resource stats + live-agent workload, no ssh) and print its row.
|
|
382
|
+
// Publishes into the local mirror as a side effect so a same-host reader is
|
|
383
|
+
// instantly warm too.
|
|
384
|
+
if (opts.local) {
|
|
385
|
+
const { publishLocalFleetStatus } = await import('../lib/fleet-status.js');
|
|
386
|
+
const row = await publishLocalFleetStatus(self);
|
|
387
|
+
if (opts.json)
|
|
388
|
+
console.log(JSON.stringify(row, null, 2));
|
|
389
|
+
else
|
|
390
|
+
console.log(`${self}: ${row.agents.running} running agent(s), ${row.agents.live} live`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
366
393
|
const planned = planFleetTargets(reg);
|
|
367
394
|
const probeable = planned.filter((t) => !t.skip).map((t) => t.device);
|
|
368
395
|
// Cache-first: serve remote stats from the daemon-warmed cache (instant),
|
|
@@ -429,6 +456,47 @@ async function runFleetStatus(opts) {
|
|
|
429
456
|
row.lastSeen = profile.tailscale?.lastSeen ?? profile.reachability?.checkedAt;
|
|
430
457
|
}
|
|
431
458
|
}
|
|
459
|
+
// Live-agent workload (RUSH-2061): publish THIS host's row, then union peers'
|
|
460
|
+
// rows cache-first. The daemon no longer probes the fleet (publish-own /
|
|
461
|
+
// read-union), so cross-host counts are gathered HERE, on demand — a mirror row
|
|
462
|
+
// younger than the freshness window is served without ssh; a missing/stale one
|
|
463
|
+
// is read over ssh via `fleet status --local --json` (bounded + kill-on-timeout
|
|
464
|
+
// through sshExecAsync/fanOutDevices, RUSH-2114). Best-effort: agent counts are
|
|
465
|
+
// additive, so a failed gather never breaks the status render.
|
|
466
|
+
try {
|
|
467
|
+
const { publishLocalFleetStatus, readFleetStatus, writeFleetStatusRows } = await import('../lib/fleet-status.js');
|
|
468
|
+
const selfRow = await publishLocalFleetStatus(self);
|
|
469
|
+
const mirror = readFleetStatus();
|
|
470
|
+
const now = Date.now();
|
|
471
|
+
const AGENT_STATUS_STALE_MS = 3 * 60_000;
|
|
472
|
+
const toRead = remoteTargets.filter((t) => {
|
|
473
|
+
if (t.skip)
|
|
474
|
+
return false;
|
|
475
|
+
if (forceRefresh)
|
|
476
|
+
return true;
|
|
477
|
+
const row = mirror[t.name];
|
|
478
|
+
return !row || now - row.capturedAt > AGENT_STATUS_STALE_MS;
|
|
479
|
+
});
|
|
480
|
+
if (toRead.length > 0) {
|
|
481
|
+
const gathered = await fanOutDevices(toRead, probeRemoteFleetStatus, { perDeviceTimeoutMs: 20_000 });
|
|
482
|
+
const updates = {};
|
|
483
|
+
for (const g of gathered) {
|
|
484
|
+
if (g.status === 'ok' && g.value)
|
|
485
|
+
updates[g.name] = { ...g.value, host: g.name };
|
|
486
|
+
}
|
|
487
|
+
if (Object.keys(updates).length > 0)
|
|
488
|
+
writeFleetStatusRows(updates);
|
|
489
|
+
}
|
|
490
|
+
const union = readFleetStatus();
|
|
491
|
+
for (const row of rows) {
|
|
492
|
+
const r = row.name === self ? selfRow : union[row.name];
|
|
493
|
+
if (r)
|
|
494
|
+
row.agents = r.agents;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// best-effort — agent counts are additive to the health view
|
|
499
|
+
}
|
|
432
500
|
const report = buildFleetHealthReport(rows, new Date(), { self });
|
|
433
501
|
if (opts.json) {
|
|
434
502
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -1110,6 +1178,7 @@ Typical workflow:
|
|
|
1110
1178
|
.option('--no-stats', 'skip the live resource probe')
|
|
1111
1179
|
.option('--refresh', 'force a live probe of every device, bypassing the cache')
|
|
1112
1180
|
.option('--live', 'alias of --refresh (shorter to type)')
|
|
1181
|
+
.option('--local', "this machine only: print THIS host's status row (resource stats + live-agent workload). The publish endpoint the fleet-status read-union reads over ssh.")
|
|
1113
1182
|
.option('--verbose', 'show the full per-device auth/CLI/sync/version grid instead of the summary')
|
|
1114
1183
|
.action(async (opts, cmd) => {
|
|
1115
1184
|
// The root program also defines a global `--verbose`; commander binds a
|