@phnx-labs/agents-cli 1.21.0 → 1.21.2
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 +198 -0
- package/README.md +2 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.js +5 -2
- package/dist/commands/feed.js +63 -40
- package/dist/commands/hooks.js +9 -45
- package/dist/commands/menubar.js +24 -24
- package/dist/commands/message.js +23 -3
- package/dist/commands/monitors.js +8 -6
- package/dist/commands/perf.d.ts +13 -0
- package/dist/commands/perf.js +80 -23
- package/dist/commands/projects.d.ts +11 -0
- package/dist/commands/projects.js +153 -21
- package/dist/commands/routines.js +46 -1
- 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 +14 -1
- package/dist/lib/activity.d.ts +6 -0
- package/dist/lib/activity.js +3 -0
- 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 +18 -0
- package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
- package/dist/lib/channels/resolve.d.ts +18 -1
- package/dist/lib/channels/resolve.js +15 -5
- package/dist/lib/cli-resources.d.ts +20 -0
- package/dist/lib/cli-resources.js +48 -1
- 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/feed-broadcast.d.ts +93 -12
- package/dist/lib/feed-broadcast.js +235 -30
- package/dist/lib/feed-post.d.ts +16 -2
- package/dist/lib/feed-post.js +35 -7
- package/dist/lib/feed.js +1 -1
- 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/hooks/cache.js +28 -6
- package/dist/lib/hooks/profile.d.ts +8 -0
- package/dist/lib/hooks/profile.js +14 -4
- package/dist/lib/hooks.d.ts +16 -1
- package/dist/lib/hooks.js +133 -18
- 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/monitors/config.d.ts +1 -1
- package/dist/lib/monitors/dispatch.d.ts +6 -2
- package/dist/lib/monitors/dispatch.js +10 -15
- package/dist/lib/notify.d.ts +42 -6
- package/dist/lib/notify.js +41 -32
- package/dist/lib/perf/db.d.ts +6 -1
- package/dist/lib/perf/db.js +35 -5
- package/dist/lib/perf/types.d.ts +10 -0
- 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/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 +39 -0
- package/dist/lib/runner.js +34 -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 +2 -1
- package/dist/lib/session/db.js +14 -3
- package/dist/lib/session/discover.d.ts +3 -0
- package/dist/lib/session/discover.js +8 -0
- package/dist/lib/session/types.d.ts +1 -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 +14 -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';
|
|
@@ -57,7 +58,7 @@ function parseContextFlag(raw) {
|
|
|
57
58
|
return { path: raw.slice(0, i).trim(), purpose: raw.slice(i + 1).trim() };
|
|
58
59
|
}
|
|
59
60
|
/** Best-effort `owner/repo` from a repo's origin remote. */
|
|
60
|
-
function originSlug(cwd) {
|
|
61
|
+
export function originSlug(cwd) {
|
|
61
62
|
try {
|
|
62
63
|
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8' }).trim();
|
|
63
64
|
return parseOwnerRepoFromRemote(url) ?? undefined;
|
|
@@ -94,7 +95,9 @@ function runFactoryImport(existing, opts) {
|
|
|
94
95
|
: Array.isArray(parsed?.projects)
|
|
95
96
|
? parsed.projects
|
|
96
97
|
: [];
|
|
97
|
-
|
|
98
|
+
// The checkout's real `origin` overrides the registry's path-derived slug —
|
|
99
|
+
// see buildFactoryImportCandidates for why the path guess is not trustworthy.
|
|
100
|
+
return buildFactoryImportCandidates(rows, existing, opts, (root) => originSlug(expandLocalHome(root)));
|
|
98
101
|
}
|
|
99
102
|
/**
|
|
100
103
|
* List the workspace's Linear projects and plan their import, binding each to a
|
|
@@ -183,6 +186,40 @@ export function formatMilestoneDue(targetDate, nowMs) {
|
|
|
183
186
|
});
|
|
184
187
|
return `due ${label}`;
|
|
185
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* The milestone block. `status` prints one line — the next checkpoint — because
|
|
191
|
+
* a roll-up across projects has to stay scannable. `view` prints every declared
|
|
192
|
+
* milestone, because "how many are there and when are they due" is the shape of
|
|
193
|
+
* the plan and the reason to open one project.
|
|
194
|
+
*
|
|
195
|
+
* Pure (chalk only) so the layout is testable without a Linear account.
|
|
196
|
+
*/
|
|
197
|
+
export function formatMilestoneLines(milestones, next, nowMs, limit) {
|
|
198
|
+
if (milestones.length === 0) {
|
|
199
|
+
// `next` without a list only happens on a cached answer written before the
|
|
200
|
+
// list existed; render what we have rather than dropping the row.
|
|
201
|
+
return next ? [` ${chalk.dim('next')} ${formatNextMilestone(next, nowMs)}`] : [];
|
|
202
|
+
}
|
|
203
|
+
// The next milestone leads, always. Linear can flag a LATER-dated milestone
|
|
204
|
+
// as next, and `milestones` is date-ordered — so slicing the front would show
|
|
205
|
+
// an earlier one and hide the actual next behind "+N more", which is the one
|
|
206
|
+
// thing this row exists to say. Identity is name+targetDate: two milestones
|
|
207
|
+
// can share a name, and matching on name alone labelled the wrong row.
|
|
208
|
+
const key = (m) => `${m.name}${m.targetDate ?? ''}`;
|
|
209
|
+
const lead = next ? milestones.filter((m) => key(m) === key(next)).slice(0, 1) : [];
|
|
210
|
+
const others = next ? milestones.filter((m) => key(m) !== key(next)) : milestones;
|
|
211
|
+
const ordered = [...lead, ...others];
|
|
212
|
+
const shown = ordered.slice(0, Math.max(1, limit));
|
|
213
|
+
const out = shown.map((m, i) => {
|
|
214
|
+
const label = i === 0 && lead.length > 0 ? 'next' : i === 0 ? 'plan' : '';
|
|
215
|
+
return ` ${chalk.dim(label.padEnd(4))} ${formatNextMilestone(m, nowMs)}`;
|
|
216
|
+
});
|
|
217
|
+
const rest = milestones.length - shown.length;
|
|
218
|
+
if (rest > 0) {
|
|
219
|
+
out.push(` ${' '.repeat(4)} ${chalk.dim(`+${rest} more milestone${rest === 1 ? '' : 's'} — agents projects view <name>`)}`);
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
186
223
|
/** The `next` card line: what this project is due to hit, and how far along it is. */
|
|
187
224
|
export function formatNextMilestone(ms, nowMs) {
|
|
188
225
|
const parts = [chalk.bold(ms.name)];
|
|
@@ -206,25 +243,39 @@ function statusBar(r) {
|
|
|
206
243
|
push(r.byStatus.input_required, 'need-input', chalk.yellow);
|
|
207
244
|
push(r.byStatus.queued, 'queued', chalk.gray);
|
|
208
245
|
const shown = (r.byStatus.running ?? 0) + (r.byStatus.idle ?? 0) + (r.byStatus.input_required ?? 0) + (r.byStatus.queued ?? 0);
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
246
|
+
// The remainder is the LIVE sessions in a state without its own chip
|
|
247
|
+
// (orphaned, unknown). Dead ones are on their own row now, so counting them
|
|
248
|
+
// here made the line disagree with the headline — `19 live` beside
|
|
249
|
+
// `+24 other`, which invites the reader to trust neither.
|
|
250
|
+
const live = liveDeadSplit(r.byStatus).live;
|
|
251
|
+
if (live > shown)
|
|
252
|
+
parts.push(chalk.gray(`+${live - shown} other`));
|
|
213
253
|
return parts.join(' · ') || chalk.gray('no live agents');
|
|
214
254
|
}
|
|
215
|
-
function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
255
|
+
function renderCard(def, r, remote, fleet, linear, nowMs = Date.now(),
|
|
256
|
+
/** How many milestones to print. `status` shows the next one; `view` shows all. */
|
|
257
|
+
milestoneLimit = 1) {
|
|
258
|
+
// The headline counts LIVE agents. It used to be every matched session, which
|
|
259
|
+
// read `39 agents` on a project where 19 had crashed. `planPct` used to sit
|
|
260
|
+
// here too and is gone: it summed each session's latest checklist snapshot,
|
|
261
|
+
// so one agent opening a fresh 40-item plan rendered the whole project `0%`.
|
|
262
|
+
const split = r ? liveDeadSplit(r.byStatus) : { live: 0, dead: 0, deadByStatus: [] };
|
|
263
|
+
console.log(`${chalk.bold(def.name)} ${chalk.dim('·')} ${chalk.bold(`${split.live} live`)}`);
|
|
220
264
|
if (def.description)
|
|
221
265
|
console.log(` ${chalk.dim(def.description)}`);
|
|
222
266
|
console.log(` ${chalk.dim('live')} ${r ? statusBar(r) : chalk.gray('no live agents')}`);
|
|
267
|
+
if (split.dead > 0) {
|
|
268
|
+
// Wreckage is worth a number of its own — 19 crashed sessions is a thing to
|
|
269
|
+
// go fix, not a throughput signal to fold into the headline.
|
|
270
|
+
const detail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
|
|
271
|
+
console.log(` ${chalk.dim('dead')} ${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${detail})`)}`);
|
|
272
|
+
}
|
|
223
273
|
if (r && r.members.length)
|
|
224
274
|
console.log(` ${chalk.dim('agents')} ${formatProjectMembers(r.members)}`);
|
|
225
275
|
const ships = [];
|
|
226
|
-
if (remote?.mergedPrs)
|
|
227
|
-
ships.push(chalk.green(`${remote.mergedPrs} merged (${remote.windowDays}d)`));
|
|
276
|
+
if (remote?.mergedPrs) {
|
|
277
|
+
ships.push(chalk.green(`${remote.mergedPrs}${remote.mergedPrsTruncated ? '+' : ''} merged (${remote.windowDays}d)`));
|
|
278
|
+
}
|
|
228
279
|
if (r?.openPrs.length)
|
|
229
280
|
ships.push(`${r.openPrs.length} open PR${r.openPrs.length === 1 ? '' : 's'}`);
|
|
230
281
|
if (r?.worktrees)
|
|
@@ -236,8 +287,8 @@ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
|
|
|
236
287
|
if (linear) {
|
|
237
288
|
console.log(` ${chalk.dim('linear')} ${linear.done}/${linear.total}${linear.truncated ? '+' : ''} done · ${linear.inProgress} in progress`);
|
|
238
289
|
}
|
|
239
|
-
|
|
240
|
-
console.log(
|
|
290
|
+
for (const line of formatMilestoneLines(linear?.milestones ?? [], linear?.nextMilestone, nowMs, milestoneLimit)) {
|
|
291
|
+
console.log(line);
|
|
241
292
|
}
|
|
242
293
|
if (r && r.tickets.length) {
|
|
243
294
|
console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
|
|
@@ -258,6 +309,14 @@ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
|
|
|
258
309
|
const repos = [def.repo, ...(def.repos ?? []).map((x) => x.slug)].filter(Boolean);
|
|
259
310
|
if (repos.length)
|
|
260
311
|
console.log(` ${chalk.dim('repos')} ${[...new Set(repos)].join(' · ')}`);
|
|
312
|
+
// A def whose `repo` disagrees with the checkout's origin reads PR and release
|
|
313
|
+
// counts from a DIFFERENT repository — both slugs resolve, so nothing errors
|
|
314
|
+
// and the wrong numbers look right. Say it here, with the fix attached.
|
|
315
|
+
const mismatch = def.root ? checkRepoSlug(def, originSlug(expandLocalHome(def.root))) : undefined;
|
|
316
|
+
if (mismatch) {
|
|
317
|
+
console.log(` ${chalk.yellow('!')} ${chalk.yellow(mismatch.message)}`);
|
|
318
|
+
console.log(` ${' '.repeat(8)}${chalk.gray(mismatch.remediation)}`);
|
|
319
|
+
}
|
|
261
320
|
if (def.contexts?.length) {
|
|
262
321
|
console.log(` ${chalk.dim('context')} ${def.contexts.map((c) => c.path).join(' · ')}`);
|
|
263
322
|
}
|
|
@@ -381,17 +440,24 @@ export function registerProjectsCommands(program) {
|
|
|
381
440
|
});
|
|
382
441
|
// ---- show ----
|
|
383
442
|
projects
|
|
384
|
-
.command('
|
|
385
|
-
.
|
|
443
|
+
.command('view <name>')
|
|
444
|
+
.alias('show')
|
|
445
|
+
.description('Show a project definition, resolved paths, repos, contexts, links, and milestones.')
|
|
386
446
|
.option('--json', 'Machine-readable output')
|
|
387
|
-
.
|
|
447
|
+
.option('--no-remote', 'Skip the Linear lookup; definition only')
|
|
448
|
+
.action(async (name, opts) => {
|
|
388
449
|
const def = loadProjectDef(name);
|
|
389
450
|
if (!def) {
|
|
390
451
|
console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
|
|
391
452
|
process.exit(1);
|
|
392
453
|
}
|
|
454
|
+
// Every milestone the project declares, not just the next one — the whole
|
|
455
|
+
// point of opening ONE project is to see the shape of its plan.
|
|
456
|
+
const counts = opts.remote !== false && def.linear?.projectId
|
|
457
|
+
? await fetchLinearProjectCounts(def.linear.projectId)
|
|
458
|
+
: undefined;
|
|
393
459
|
if (opts.json) {
|
|
394
|
-
console.log(JSON.stringify(def, null, 2));
|
|
460
|
+
console.log(JSON.stringify({ ...def, linear: { ...def.linear, ...(counts ?? {}) } }, null, 2));
|
|
395
461
|
return;
|
|
396
462
|
}
|
|
397
463
|
console.log(chalk.bold(def.name) + (def.description ? chalk.dim(` — ${def.description}`) : ''));
|
|
@@ -410,6 +476,24 @@ export function registerProjectsCommands(program) {
|
|
|
410
476
|
console.log(` linear ${def.linear.url ?? def.linear.projectId}`);
|
|
411
477
|
for (const d of def.docs ?? [])
|
|
412
478
|
console.log(` doc ${d}`);
|
|
479
|
+
if (counts) {
|
|
480
|
+
const staleNote = counts.stale ? chalk.dim(' (cached)') : '';
|
|
481
|
+
console.log(` issues ${counts.done}/${counts.total}${counts.truncated ? '+' : ''} done · ${counts.inProgress} in progress${staleNote}`);
|
|
482
|
+
}
|
|
483
|
+
const ms = counts?.milestones ?? [];
|
|
484
|
+
if (ms.length) {
|
|
485
|
+
console.log(` ${chalk.bold('milestones')}`);
|
|
486
|
+
for (const m of ms) {
|
|
487
|
+
const mark = m.name === counts?.nextMilestone?.name ? chalk.cyan('next') : '';
|
|
488
|
+
console.log(` ${mark.padEnd(6)} ${formatNextMilestone(m, Date.now())}`);
|
|
489
|
+
}
|
|
490
|
+
// A milestone nothing is filed against cannot report progress. Say that
|
|
491
|
+
// once, plainly, rather than printing a row of silent 0%s.
|
|
492
|
+
const unfiled = ms.filter((m) => m.total === 0).length;
|
|
493
|
+
if (unfiled === ms.length) {
|
|
494
|
+
console.log(` ${chalk.yellow('!')} ${chalk.yellow(`no issues are assigned to any milestone — progress against them cannot be measured`)}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
413
497
|
console.log(chalk.gray(` ${projectDefPath(name)}`));
|
|
414
498
|
});
|
|
415
499
|
// ---- edit ----
|
|
@@ -514,7 +598,8 @@ export function registerProjectsCommands(program) {
|
|
|
514
598
|
byStatus: r?.byStatus ?? {},
|
|
515
599
|
members: r?.members ?? [],
|
|
516
600
|
plan: r?.plan ?? { done: 0, total: 0 },
|
|
517
|
-
|
|
601
|
+
live: r ? liveDeadSplit(r.byStatus).live : 0,
|
|
602
|
+
dead: r ? liveDeadSplit(r.byStatus).dead : 0,
|
|
518
603
|
openPrs: r?.openPrs ?? [],
|
|
519
604
|
mergedPrs: rem?.mergedPrs ?? 0,
|
|
520
605
|
latestRelease: rem?.latestRelease ?? null,
|
|
@@ -577,6 +662,53 @@ export function registerProjectsCommands(program) {
|
|
|
577
662
|
console.log(chalk.gray(' (widen with --min-confidence medium or --all)'));
|
|
578
663
|
}
|
|
579
664
|
});
|
|
665
|
+
// ---- set ----
|
|
666
|
+
projects
|
|
667
|
+
.command('set <name>')
|
|
668
|
+
.description('Change one field on a project definition, preserving everything else.')
|
|
669
|
+
.option('--repo <owner/repo>', 'Primary GitHub slug')
|
|
670
|
+
.option('--root <path>', 'Repo / monorepo root')
|
|
671
|
+
.option('--path <subdir>', 'Default cwd for agents (a monorepo subdir)')
|
|
672
|
+
.option('--description <text>', 'One-line description shown on the card')
|
|
673
|
+
.action((name, opts) => {
|
|
674
|
+
const def = loadProjectDef(name);
|
|
675
|
+
if (!def) {
|
|
676
|
+
console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
|
|
677
|
+
process.exit(1);
|
|
678
|
+
}
|
|
679
|
+
const fields = ['repo', 'root', 'path', 'description'].filter((k) => opts[k] !== undefined);
|
|
680
|
+
if (fields.length === 0) {
|
|
681
|
+
console.error(chalk.red('Nothing to set. Pass a field, e.g. --repo <owner/repo>.'));
|
|
682
|
+
process.exit(1);
|
|
683
|
+
}
|
|
684
|
+
// Load, mutate the named fields, write back — the `link` pattern. NEVER
|
|
685
|
+
// the `add --force` pattern, which rebuilds the def from flags alone and
|
|
686
|
+
// silently drops linear/contexts/integrations that were not re-passed.
|
|
687
|
+
if (opts.repo !== undefined)
|
|
688
|
+
def.repo = opts.repo;
|
|
689
|
+
if (opts.root !== undefined)
|
|
690
|
+
def.root = opts.root;
|
|
691
|
+
if (opts.description !== undefined)
|
|
692
|
+
def.description = opts.description;
|
|
693
|
+
if (opts.path !== undefined) {
|
|
694
|
+
// `--path` is a subdir OF the root, so without one there is nothing to
|
|
695
|
+
// hang it off. A def imported with `--from-linear` that found no local
|
|
696
|
+
// checkout carries name + linear and no root — joining against '' there
|
|
697
|
+
// would silently write `/apps/cli`, an absolute path at the filesystem
|
|
698
|
+
// root, and the def would resolve somewhere that does not exist.
|
|
699
|
+
const base = (opts.root ?? def.root ?? '').replace(/\/$/, '');
|
|
700
|
+
if (!base) {
|
|
701
|
+
console.error(chalk.red(`"${def.name}" has no root, so --path has nothing to resolve against.`));
|
|
702
|
+
console.error(chalk.gray(` Set one first: agents projects set ${def.name} --root <path> --path ${opts.path}`));
|
|
703
|
+
process.exit(1);
|
|
704
|
+
}
|
|
705
|
+
def.defaultPath = `${base}/${opts.path.replace(/^\//, '')}`;
|
|
706
|
+
}
|
|
707
|
+
writeProjectDef(def);
|
|
708
|
+
console.log(chalk.green(`Updated ${def.name}`));
|
|
709
|
+
for (const f of fields)
|
|
710
|
+
console.log(chalk.gray(` ${f} ${f === 'path' ? def.defaultPath : def[f]}`));
|
|
711
|
+
});
|
|
580
712
|
// ---- link ----
|
|
581
713
|
projects
|
|
582
714
|
.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.')
|
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
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { setHelpSections } from '../lib/help.js';
|
|
3
|
+
import { buildTrendsDashboard, trendsWindow, runRecipe, RECIPE_IDS } from '../lib/analytics/dashboard.js';
|
|
4
|
+
import { listRecipes } from '../lib/analytics/recipes.js';
|
|
5
|
+
import { queryUsage, usageDbPath, USAGE_KINDS } from '../lib/analytics/usage-db.js';
|
|
6
|
+
function parseDays(raw) {
|
|
7
|
+
const n = parseInt(raw ?? '7', 10);
|
|
8
|
+
return Number.isFinite(n) && n > 0 ? n : 7;
|
|
9
|
+
}
|
|
10
|
+
function parseLimit(raw, fallback) {
|
|
11
|
+
const n = parseInt(raw ?? String(fallback), 10);
|
|
12
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
13
|
+
}
|
|
14
|
+
function printSection(section) {
|
|
15
|
+
console.log(chalk.bold(section.title));
|
|
16
|
+
if (section.rows.length === 0) {
|
|
17
|
+
console.log(chalk.gray(' (no data)'));
|
|
18
|
+
console.log();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const keys = Object.keys(section.rows[0]);
|
|
22
|
+
const widths = keys.map((k) => Math.max(k.length, ...section.rows.map((r) => String(r[k] ?? '').length), 4));
|
|
23
|
+
const pad = (s, w) => (s.length >= w ? s.slice(0, w) : s + ' '.repeat(w - s.length));
|
|
24
|
+
console.log(chalk.gray(keys.map((k, i) => pad(k.toUpperCase(), widths[i])).join(' ')));
|
|
25
|
+
for (const row of section.rows) {
|
|
26
|
+
console.log(keys.map((k, i) => pad(String(row[k] ?? ''), widths[i])).join(' '));
|
|
27
|
+
}
|
|
28
|
+
console.log();
|
|
29
|
+
}
|
|
30
|
+
function renderDashboard(days, asJson) {
|
|
31
|
+
const dash = buildTrendsDashboard({ days });
|
|
32
|
+
if (asJson) {
|
|
33
|
+
console.log(JSON.stringify(dash, null, 2));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
console.log(chalk.bold(`agents trends — last ${dash.window.days} days`));
|
|
37
|
+
console.log(chalk.gray(`compute ${dash.durationMs}ms · usage ${usageDbPath()}`));
|
|
38
|
+
console.log();
|
|
39
|
+
if (dash.sections.length === 0) {
|
|
40
|
+
console.log(chalk.gray('No session or usage data in this window yet.'));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const section of dash.sections)
|
|
44
|
+
printSection(section);
|
|
45
|
+
}
|
|
46
|
+
export function registerTrendsCommand(program) {
|
|
47
|
+
const trends = program
|
|
48
|
+
.command('trends')
|
|
49
|
+
.description('Usage analytics — harness/model mix, token ratios, resource frequency')
|
|
50
|
+
.option('--days <n>', 'Days of history to include', '7')
|
|
51
|
+
.option('--json', 'Emit JSON instead of tables')
|
|
52
|
+
.action(function summary() {
|
|
53
|
+
const opts = this.opts();
|
|
54
|
+
renderDashboard(parseDays(opts.days), Boolean(opts.json));
|
|
55
|
+
});
|
|
56
|
+
setHelpSections(trends, {
|
|
57
|
+
examples: `
|
|
58
|
+
# Auto recipe dashboard (7d)
|
|
59
|
+
agents trends
|
|
60
|
+
|
|
61
|
+
# Last 30 days
|
|
62
|
+
agents trends --days 30
|
|
63
|
+
|
|
64
|
+
# One recipe as JSON
|
|
65
|
+
agents trends harness-mix --json
|
|
66
|
+
|
|
67
|
+
# Raw usage events
|
|
68
|
+
agents trends query --kind secret --days 7
|
|
69
|
+
|
|
70
|
+
# List baked recipe ids
|
|
71
|
+
agents trends recipes
|
|
72
|
+
`,
|
|
73
|
+
notes: `
|
|
74
|
+
Session recipes read sessions.db; resource recipes read ~/.agents/.history/analytics/usage.db.
|
|
75
|
+
Empty recipes are skipped on the default dashboard.
|
|
76
|
+
Quota / rate-limits remain on \`agents usage\`; latency on \`agents perf\`.
|
|
77
|
+
`,
|
|
78
|
+
});
|
|
79
|
+
trends.command('recipes')
|
|
80
|
+
.description('List baked recipe ids')
|
|
81
|
+
.option('--json', 'Emit JSON')
|
|
82
|
+
.action((opts) => {
|
|
83
|
+
const list = listRecipes();
|
|
84
|
+
if (opts.json) {
|
|
85
|
+
console.log(JSON.stringify(list, null, 2));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const r of list) {
|
|
89
|
+
console.log(`${r.id.padEnd(22)} ${r.store.padEnd(10)} ${r.title}`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
trends.command('query')
|
|
93
|
+
.description('Raw usage-event query')
|
|
94
|
+
.option('--kind <kind>', `One of: ${USAGE_KINDS.join(', ')}`)
|
|
95
|
+
.option('--name <name>', 'Resource name filter')
|
|
96
|
+
.option('--event <event>', 'Event name filter')
|
|
97
|
+
.option('--days <n>', 'Days of history', '7')
|
|
98
|
+
.option('--limit <n>', 'Max rows', '40')
|
|
99
|
+
.option('--json', 'Emit JSON')
|
|
100
|
+
.action((opts) => {
|
|
101
|
+
const win = trendsWindow(parseDays(opts.days));
|
|
102
|
+
const kind = opts.kind && USAGE_KINDS.includes(opts.kind)
|
|
103
|
+
? opts.kind
|
|
104
|
+
: undefined;
|
|
105
|
+
if (opts.kind && !kind) {
|
|
106
|
+
console.error(`Unknown kind '${opts.kind}'. Expected: ${USAGE_KINDS.join(', ')}`);
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const rows = queryUsage({
|
|
111
|
+
kind,
|
|
112
|
+
name: opts.name,
|
|
113
|
+
event: opts.event,
|
|
114
|
+
sinceIso: win.sinceIso,
|
|
115
|
+
limit: parseLimit(opts.limit, 40),
|
|
116
|
+
});
|
|
117
|
+
if (opts.json) {
|
|
118
|
+
console.log(JSON.stringify({ window: win, rows }, null, 2));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (rows.length === 0) {
|
|
122
|
+
console.log(chalk.gray('No usage events.'));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
printSection({
|
|
126
|
+
id: 'query',
|
|
127
|
+
title: 'Usage events',
|
|
128
|
+
rows: rows.map((r) => ({
|
|
129
|
+
ts: r.ts,
|
|
130
|
+
kind: r.kind,
|
|
131
|
+
name: r.name,
|
|
132
|
+
event: r.event,
|
|
133
|
+
agent: r.agent,
|
|
134
|
+
})),
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
for (const id of RECIPE_IDS) {
|
|
138
|
+
trends.command(id)
|
|
139
|
+
.description(`Recipe: ${id}`)
|
|
140
|
+
.option('--days <n>', 'Days of history', '7')
|
|
141
|
+
.option('--json', 'Emit JSON')
|
|
142
|
+
.action(function recipeAction() {
|
|
143
|
+
const parent = this.parent?.opts?.();
|
|
144
|
+
const opts = { ...parent, ...this.opts() };
|
|
145
|
+
const win = trendsWindow(parseDays(opts.days));
|
|
146
|
+
const section = runRecipe(id, win);
|
|
147
|
+
if (opts.json) {
|
|
148
|
+
console.log(JSON.stringify({ window: win, section }, null, 2));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (section.empty) {
|
|
152
|
+
console.log(chalk.gray(`No data for recipe '${id}' in the last ${win.days} days.`));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
printSection(section);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
package/dist/commands/usage.d.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* Usage command -- show rate-limit / quota status for each installed agent.
|
|
3
3
|
*
|
|
4
4
|
* Lists every installed agent with the best available usage snapshot:
|
|
5
|
-
* - claude: live OAuth API call (cached for
|
|
5
|
+
* - claude: live OAuth API call (cached for 5 minutes)
|
|
6
6
|
* - codex: parsed from latest session log's rate_limits event
|
|
7
|
-
* - kimi: live Kimi Code /usages API call (cached for
|
|
8
|
-
* - droid: live Factory billing/limits API call (cached for
|
|
7
|
+
* - kimi: live Kimi Code /usages API call (cached for 5 minutes)
|
|
8
|
+
* - droid: live Factory billing/limits API call (cached for 5 minutes)
|
|
9
9
|
* - grok: parsed from the latest local usage event
|
|
10
|
-
* - cursor: live Cursor usage API call (cached for
|
|
10
|
+
* - cursor: live Cursor usage API call (cached for 5 minutes)
|
|
11
11
|
* - others: marked as "not exposed by CLI"
|
|
12
12
|
*/
|
|
13
13
|
import type { Command } from 'commander';
|
package/dist/commands/view.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ import { type ConfiguredModelSource } from '../lib/models.js';
|
|
|
14
14
|
import { type ProfileSummary } from '../lib/profiles.js';
|
|
15
15
|
/** Shared account identity formatter, re-exported for the view-specific tests. */
|
|
16
16
|
export declare const accountColumnLabel: typeof accountDisplayLabel;
|
|
17
|
+
/**
|
|
18
|
+
* Join fixed view columns with a consistent two-space gutter. Empty trailing
|
|
19
|
+
* columns are dropped so a row without an auth chip does not grow a dangling
|
|
20
|
+
* gutter, but interior empties stay padded so later columns stay aligned.
|
|
21
|
+
*/
|
|
22
|
+
export declare function joinViewColumns(cols: string[]): string;
|
|
17
23
|
type SyncState = 'synced' | 'new' | 'modified' | 'deleted';
|
|
18
24
|
/** Per-section filter flags. When any are true, only those sections render. */
|
|
19
25
|
export interface ViewSectionFilter {
|