@phnx-labs/agents-cli 1.20.30 → 1.20.32

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.
@@ -11,6 +11,7 @@ import { summarizeToolUse } from './parse.js';
11
11
  import { cleanSessionPrompt, extractSessionTopic } from './prompt.js';
12
12
  import { renderMarkdown } from '../markdown.js';
13
13
  import { redactSecrets } from '../redact.js';
14
+ import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from './digest.js';
14
15
  // ── Path helpers ──────────────────────────────────────────────────────────────
15
16
  /**
16
17
  * Return absPath relative to cwd; fall back to ~/… then absolute.
@@ -153,6 +154,7 @@ export function collapseRetries(commands) {
153
154
  /** Compute aggregate statistics (turns, tools, tokens, duration) from session events. */
154
155
  export function computeSummaryStats(events) {
155
156
  const modelSet = new Set();
157
+ const toolCounts = {};
156
158
  let userTurns = 0;
157
159
  let assistantTurns = 0;
158
160
  let toolCount = 0;
@@ -177,6 +179,8 @@ export function computeSummaryStats(events) {
177
179
  }
178
180
  else if (e.type === 'tool_use' && !e._local) {
179
181
  toolCount++;
182
+ if (e.tool)
183
+ toolCounts[e.tool] = (toolCounts[e.tool] ?? 0) + 1;
180
184
  }
181
185
  else if (e.type === 'error') {
182
186
  errorCount++;
@@ -193,6 +197,7 @@ export function computeSummaryStats(events) {
193
197
  userTurns,
194
198
  assistantTurns,
195
199
  toolCount,
200
+ toolCounts,
196
201
  errorCount,
197
202
  outputTokens,
198
203
  cacheReadTokens,
@@ -426,6 +431,76 @@ function renderActivityLine(item) {
426
431
  return chalk.green('Msg ') + ' ' + chalk.gray('"' + trim(item.label) + '"');
427
432
  }
428
433
  }
434
+ // ── Catch-up digest sections ──────────────────────────────────────────────────
435
+ const OP_GLYPH = {
436
+ created: (s) => chalk.green(s),
437
+ modified: (s) => chalk.yellow(s),
438
+ deleted: (s) => chalk.red(s),
439
+ };
440
+ const OP_MARK = { created: '+', modified: '~', deleted: '−' };
441
+ /**
442
+ * Render the Changes section: files grouped by directory, each tagged with its
443
+ * create/modify/delete lifecycle, plus a `+N ~N −N` summary. Replaces the old
444
+ * flat "Modified" list. Returns true if anything was rendered.
445
+ */
446
+ function renderChangesSection(lines, events, cwd) {
447
+ // In-project changes only; edits outside cwd (e.g. /tmp) keep their own
448
+ // "External edits" section so they don't clutter the project's changeset.
449
+ const inCwd = (p) => !cwd || !p.startsWith('/') || p.startsWith(cwd + '/');
450
+ const changes = classifyFileChanges(events).filter(ch => inCwd(ch.path));
451
+ if (changes.length === 0)
452
+ return false;
453
+ const c = changeCounts(changes);
454
+ const opByRel = new Map();
455
+ for (const ch of changes)
456
+ opByRel.set(relativeToCwd(ch.path, cwd), ch.op);
457
+ const summary = [
458
+ c.created ? chalk.green(`+${c.created}`) : '',
459
+ c.modified ? chalk.yellow(`~${c.modified}`) : '',
460
+ c.deleted ? chalk.red(`−${c.deleted}`) : '',
461
+ ].filter(Boolean).join(' ');
462
+ lines.push(chalk.bold('Changes') + chalk.gray(` (${changes.length}) `) + summary);
463
+ const groups = groupByParentDir(changes.map(ch => ch.path), cwd);
464
+ const single = groups.size === 1;
465
+ for (const [dir, files] of groups) {
466
+ // Single dir: show the full relative path per file (dir/base). Multiple
467
+ // dirs: a dir header, then bare filenames under it.
468
+ if (!single)
469
+ lines.push(' ' + chalk.dim(dir + '/'));
470
+ for (const f of files.sort()) {
471
+ const rel = dir === '.' ? f : `${dir}/${f}`;
472
+ const op = opByRel.get(rel) ?? 'modified';
473
+ const shown = single ? rel : f;
474
+ const name = op === 'deleted' ? chalk.strikethrough(chalk.gray(shown)) : shown;
475
+ lines.push((single ? ' ' : ' ') + OP_GLYPH[op](OP_MARK[op]) + ' ' + name);
476
+ }
477
+ }
478
+ lines.push('');
479
+ return true;
480
+ }
481
+ /** Render the tool histogram: `Edit 61 · Bash 48 · Read 35 …`. */
482
+ function renderToolsSection(lines, stats) {
483
+ const hist = toolHistogram(stats.toolCounts, 8);
484
+ if (hist.length === 0)
485
+ return;
486
+ const parts = hist.map(h => `${chalk.white(h.tool)} ${chalk.gray(String(h.count))}`);
487
+ lines.push(chalk.bold('Tools') + ' ' + parts.join(chalk.gray(' · ')));
488
+ lines.push('');
489
+ }
490
+ /** Render the last test/build verdict, e.g. `Tests tests: 294 pass · 4 fail`. */
491
+ function renderTestsLine(lines, events) {
492
+ const r = detectTestResult(events);
493
+ if (!r || !r.ok)
494
+ return;
495
+ const bits = [];
496
+ if (r.passed !== undefined)
497
+ bits.push(chalk.green(`${r.passed} pass`));
498
+ if (r.failed !== undefined)
499
+ bits.push(r.failed > 0 ? chalk.red(`${r.failed} fail`) : chalk.gray('0 fail'));
500
+ const verdict = r.failed && r.failed > 0 ? chalk.red('✗') : chalk.green('✓');
501
+ lines.push(chalk.bold('Tests') + ` ${verdict} ${chalk.cyan(r.runner)} ${bits.join(chalk.gray(' · '))}`);
502
+ lines.push('');
503
+ }
429
504
  // ── Main summary renderer ─────────────────────────────────────────────────────
430
505
  /**
431
506
  * Render session as an activity summary.
@@ -560,7 +635,6 @@ export function renderSummary(events, cwd) {
560
635
  }
561
636
  return m;
562
637
  };
563
- const modifiedAbsMap = buildAbsMap(filesModifiedAbs);
564
638
  const readAbsMap = buildAbsMap(filesReadAbs);
565
639
  // ── Render sections ───────────────────────────────────────────────────────
566
640
  const lines = [''];
@@ -640,15 +714,14 @@ export function renderSummary(events, cwd) {
640
714
  chalk.gray(`: ${errors.length} failure${errors.length !== 1 ? 's' : ''} — first: ${firstDesc}`));
641
715
  lines.push('');
642
716
  }
643
- // 6. Modified files
644
- if (filesModifiedAbs.size > 0) {
645
- lines.push(chalk.bold('Modified') + chalk.gray(` (${filesModifiedAbs.size})`));
646
- const groups = groupByParentDir(filesModifiedAbs, cwd);
647
- renderFileGroup(lines, groups, modifiedAbsMap);
648
- lines.push('');
649
- }
650
- // 6b. External edits (files edited outside the project root — typically /tmp)
651
- // Filter out plan files (already shown in Plan section)
717
+ // 6. Changes files grouped by directory with create/modify/delete lifecycle
718
+ // (replaces the old flat "Modified" + "External edits" lists).
719
+ renderChangesSection(lines, events, cwd);
720
+ // 6b. Catch-up signals: last test/build verdict, then the tool histogram.
721
+ renderTestsLine(lines, events);
722
+ renderToolsSection(lines, computeSummaryStats(events));
723
+ // 6c. External edits (files edited outside the project root — typically /tmp).
724
+ // Filter out plan files (already shown in Plan section).
652
725
  const externalNonPlan = [...filesModifiedExternal].filter(p => !(p.includes('.claude/plans/') && p.endsWith('.md')));
653
726
  if (externalNonPlan.length > 0) {
654
727
  const externalList = externalNonPlan.sort();
@@ -156,6 +156,10 @@ export declare function getTeamsAgentsDir(): string;
156
156
  export declare function getTeamsRegistryPath(): string;
157
157
  /** Path to the device registry — SSH device profiles with platform/auth metadata. Durable runtime, per-machine (host list + addresses are NOT pulled by `agents repo push`). */
158
158
  export declare function getDevicesRegistryPath(): string;
159
+ /** Path to the device ignore-list — tailscale node names the user dismissed, so auto-discovery never re-suggests them. Per-machine, same dir as the registry. */
160
+ export declare function getDevicesIgnoredPath(): string;
161
+ /** Dir of "pending device" sentinels (~/.agents/.cache/state/devices-pending/) — one empty-ish file per newly-discovered, not-yet-approved tailnet node. Written by the daemon probe, read by the menu-bar helper (mirrors the attention sentinel dir). */
162
+ export declare function getDevicesPendingDir(): string;
159
163
  /** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
160
164
  export declare function getCloudDir(): string;
161
165
  /** Path to terminal session metadata (~/.agents/.cache/terminals/). */
package/dist/lib/state.js CHANGED
@@ -352,6 +352,10 @@ export function getTeamsAgentsDir() { return TEAMS_AGENTS_DIR; }
352
352
  export function getTeamsRegistryPath() { return path.join(HISTORY_DIR, 'teams', 'registry.json'); }
353
353
  /** Path to the device registry — SSH device profiles with platform/auth metadata. Durable runtime, per-machine (host list + addresses are NOT pulled by `agents repo push`). */
354
354
  export function getDevicesRegistryPath() { return path.join(HISTORY_DIR, 'devices', 'registry.json'); }
355
+ /** Path to the device ignore-list — tailscale node names the user dismissed, so auto-discovery never re-suggests them. Per-machine, same dir as the registry. */
356
+ export function getDevicesIgnoredPath() { return path.join(HISTORY_DIR, 'devices', 'ignored.json'); }
357
+ /** Dir of "pending device" sentinels (~/.agents/.cache/state/devices-pending/) — one empty-ish file per newly-discovered, not-yet-approved tailnet node. Written by the daemon probe, read by the menu-bar helper (mirrors the attention sentinel dir). */
358
+ export function getDevicesPendingDir() { return path.join(RUNTIME_STATE_DIR, 'devices-pending'); }
355
359
  /** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
356
360
  export function getCloudDir() { return CLOUD_DIR; }
357
361
  /** Path to terminal session metadata (~/.agents/.cache/terminals/). */
@@ -57,6 +57,11 @@ export interface UmbrellaResult {
57
57
  pulled: number;
58
58
  merged: number;
59
59
  };
60
+ devices?: {
61
+ synced: number;
62
+ pending: number;
63
+ skipped: boolean;
64
+ };
60
65
  reconciled: boolean;
61
66
  }
62
67
  export interface RunUmbrellaArgs {
@@ -120,6 +120,20 @@ export async function runUmbrellaSync(args) {
120
120
  const { refresh } = await import('./refresh.js');
121
121
  await refresh({ skipPrompts: yes });
122
122
  result.reconciled = true;
123
+ // Keep already-registered devices' reachability current, and surface newly
124
+ // appeared tailnet nodes as "pending" for the menu-bar Register/Ignore gate
125
+ // rather than silently adding them (refresh mode). Soft: a machine without
126
+ // tailscale is a clean no-op, never a sync failure. First-run population is
127
+ // `agents setup` / manual `agents devices sync` (bootstrap).
128
+ const { runDeviceSync } = await import('./devices/sync.js');
129
+ const { reconcilePendingSentinels } = await import('./devices/pending.js');
130
+ const dev = await runDeviceSync({ soft: true, mode: 'refresh' });
131
+ if (dev.ok)
132
+ reconcilePendingSentinels(dev.pending);
133
+ result.devices = { synced: dev.synced, pending: dev.pending.length, skipped: !dev.ok };
134
+ if (dev.ok) {
135
+ log(`devices: ${dev.synced} refreshed${dev.pending.length ? `, ${dev.pending.length} new pending` : ''}`);
136
+ }
123
137
  }
124
138
  return result;
125
139
  }
@@ -197,6 +197,17 @@ export declare function installVersion(agent: AgentId, version: string, onProgre
197
197
  * when nothing was resolved or no stale dir is present, so it is safe to call
198
198
  * on every script-based install. Returns the action taken (for tests/logging).
199
199
  */
200
+ /**
201
+ * Proactively fold a stale `latest` version-home into its concrete version,
202
+ * WITHOUT needing a fresh install (RUSH-1320). `reconcileStaleLatestDir` only
203
+ * fires at install time, so a `latest` dir left by an old probe-failed install
204
+ * lingers in `agents view` indefinitely. This resolves the live CLI version and
205
+ * reconciles — cheap no-op when there's no `latest` dir (the common case, so
206
+ * `agents view` pays a `--version` shell-out only the once, until it's folded).
207
+ * Skipped when the active config symlink still points at `latest`, since
208
+ * renaming that dir would dangle the live symlink.
209
+ */
210
+ export declare function reconcileStaleLatestForAgent(agent: AgentId): Promise<void>;
200
211
  export declare function reconcileStaleLatestDir(agent: AgentId, installedVersion: string): Promise<'none' | 'renamed' | 'trashed'>;
201
212
  /**
202
213
  * Soft-delete a version directory by moving it to ~/.agents/.system/trash/versions/.
@@ -1209,6 +1209,26 @@ function removeInstallArtifacts(versionDir) {
1209
1209
  * when nothing was resolved or no stale dir is present, so it is safe to call
1210
1210
  * on every script-based install. Returns the action taken (for tests/logging).
1211
1211
  */
1212
+ /**
1213
+ * Proactively fold a stale `latest` version-home into its concrete version,
1214
+ * WITHOUT needing a fresh install (RUSH-1320). `reconcileStaleLatestDir` only
1215
+ * fires at install time, so a `latest` dir left by an old probe-failed install
1216
+ * lingers in `agents view` indefinitely. This resolves the live CLI version and
1217
+ * reconciles — cheap no-op when there's no `latest` dir (the common case, so
1218
+ * `agents view` pays a `--version` shell-out only the once, until it's folded).
1219
+ * Skipped when the active config symlink still points at `latest`, since
1220
+ * renaming that dir would dangle the live symlink.
1221
+ */
1222
+ export async function reconcileStaleLatestForAgent(agent) {
1223
+ if (!fs.existsSync(getVersionDir(agent, 'latest')))
1224
+ return;
1225
+ if (getConfigSymlinkVersion(agent) === 'latest')
1226
+ return;
1227
+ const concrete = await getCliVersionFromPath(agent);
1228
+ if (concrete && concrete !== 'latest') {
1229
+ await reconcileStaleLatestDir(agent, concrete);
1230
+ }
1231
+ }
1212
1232
  export async function reconcileStaleLatestDir(agent, installedVersion) {
1213
1233
  if (installedVersion === 'latest')
1214
1234
  return 'none';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.30",
3
+ "version": "1.20.32",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",