@phnx-labs/agents-cli 1.22.6 → 1.22.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +131 -1
  2. package/README.md +7 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +61 -0
  5. package/dist/commands/exec.js +83 -16
  6. package/dist/commands/feed.d.ts +2 -1
  7. package/dist/commands/feed.js +47 -20
  8. package/dist/commands/focus.js +22 -1
  9. package/dist/commands/harness.d.ts +0 -1
  10. package/dist/commands/harness.js +60 -4
  11. package/dist/commands/models.js +2 -2
  12. package/dist/commands/monitors.js +2 -2
  13. package/dist/commands/projects.js +122 -107
  14. package/dist/commands/routines.js +2 -2
  15. package/dist/commands/run-account-picker.js +2 -0
  16. package/dist/commands/secrets.js +1 -1
  17. package/dist/commands/sessions-backfill.d.ts +33 -0
  18. package/dist/commands/sessions-backfill.js +83 -1
  19. package/dist/commands/sessions-stats.d.ts +36 -0
  20. package/dist/commands/sessions-stats.js +263 -0
  21. package/dist/commands/sessions.d.ts +1 -1
  22. package/dist/commands/sessions.js +21 -1
  23. package/dist/commands/snapshot.d.ts +11 -0
  24. package/dist/commands/snapshot.js +107 -0
  25. package/dist/commands/teams.js +2 -1
  26. package/dist/commands/view.d.ts +9 -1
  27. package/dist/commands/view.js +63 -12
  28. package/dist/index.js +4 -2
  29. package/dist/lib/activity.js +2 -2
  30. package/dist/lib/agents.js +68 -11
  31. package/dist/lib/analytics/recipes.js +11 -5
  32. package/dist/lib/browser/ipc.js +2 -0
  33. package/dist/lib/browser/profiles.d.ts +15 -7
  34. package/dist/lib/browser/profiles.js +53 -12
  35. package/dist/lib/browser/remote-control.d.ts +35 -0
  36. package/dist/lib/browser/remote-control.js +48 -0
  37. package/dist/lib/browser/service.d.ts +19 -0
  38. package/dist/lib/browser/service.js +19 -1
  39. package/dist/lib/browser/types.d.ts +14 -2
  40. package/dist/lib/byok-usage.d.ts +38 -0
  41. package/dist/lib/byok-usage.js +117 -0
  42. package/dist/lib/capabilities.js +1 -1
  43. package/dist/lib/device-config.js +8 -0
  44. package/dist/lib/exec.d.ts +20 -0
  45. package/dist/lib/exec.js +73 -8
  46. package/dist/lib/feed-outcome.d.ts +1 -0
  47. package/dist/lib/feed-outcome.js +2 -0
  48. package/dist/lib/feed-post.js +1 -1
  49. package/dist/lib/feed-ranking.d.ts +1 -0
  50. package/dist/lib/feed-ranking.js +4 -0
  51. package/dist/lib/feed.d.ts +4 -1
  52. package/dist/lib/feed.js +25 -0
  53. package/dist/lib/hosts/passthrough.d.ts +10 -1
  54. package/dist/lib/hosts/passthrough.js +23 -3
  55. package/dist/lib/hosts/remote-cmd.js +1 -0
  56. package/dist/lib/mcp.js +6 -1
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/model-tiers.js +4 -1
  60. package/dist/lib/models.js +63 -0
  61. package/dist/lib/placement.d.ts +82 -0
  62. package/dist/lib/placement.js +188 -0
  63. package/dist/lib/profiles.d.ts +73 -10
  64. package/dist/lib/profiles.js +133 -15
  65. package/dist/lib/project-focus.d.ts +9 -0
  66. package/dist/lib/project-focus.js +23 -0
  67. package/dist/lib/project-key.d.ts +1 -1
  68. package/dist/lib/project-key.js +1 -1
  69. package/dist/lib/project-probe.d.ts +18 -0
  70. package/dist/lib/project-probe.js +46 -0
  71. package/dist/lib/project-status.d.ts +56 -0
  72. package/dist/lib/project-status.js +125 -18
  73. package/dist/lib/resources/mcp.js +3 -0
  74. package/dist/lib/resources/types.d.ts +1 -1
  75. package/dist/lib/rotate.d.ts +19 -4
  76. package/dist/lib/rotate.js +24 -1
  77. package/dist/lib/routines.d.ts +2 -0
  78. package/dist/lib/runner.d.ts +3 -0
  79. package/dist/lib/runner.js +90 -7
  80. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  82. package/dist/lib/secrets/audit.js +1 -1
  83. package/dist/lib/secrets/index.d.ts +1 -0
  84. package/dist/lib/secrets/index.js +29 -15
  85. package/dist/lib/secrets/remote.d.ts +1 -1
  86. package/dist/lib/secrets/remote.js +1 -1
  87. package/dist/lib/session/active.d.ts +2 -0
  88. package/dist/lib/session/bash-command.d.ts +2 -3
  89. package/dist/lib/session/db.d.ts +92 -1
  90. package/dist/lib/session/db.js +230 -1
  91. package/dist/lib/session/digest.d.ts +1 -1
  92. package/dist/lib/session/digest.js +1 -1
  93. package/dist/lib/share/publish.js +24 -0
  94. package/dist/lib/snapshot.d.ts +103 -0
  95. package/dist/lib/snapshot.js +99 -0
  96. package/dist/lib/startup/command-registry.d.ts +1 -1
  97. package/dist/lib/startup/command-registry.js +2 -2
  98. package/dist/lib/subagents-registry.js +3 -0
  99. package/dist/lib/types.d.ts +11 -2
  100. package/dist/lib/usage.d.ts +5 -0
  101. package/dist/lib/usage.js +3 -3
  102. package/package.json +1 -1
  103. package/dist/commands/activity.d.ts +0 -87
  104. package/dist/commands/activity.js +0 -346
@@ -11,6 +11,7 @@ import * as yaml from 'yaml';
11
11
  import { getUserAgentsDir } from './state.js';
12
12
  import { getKeychainToken, hasKeychainToken, keychainItemName } from './secrets/profiles.js';
13
13
  import { getPreset } from './profiles-presets.js';
14
+ import { MODEL_TIERS, isTierToken } from './model-tiers.js';
14
15
  const PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]{0,48}$/i;
15
16
  /** Get the directory where profile YAML files are stored. */
16
17
  export function getProfilesDir() {
@@ -198,11 +199,46 @@ export function profileAuthLabel(profile) {
198
199
  return provider;
199
200
  }
200
201
  /**
201
- * Header label for the harness — the slot `AGENTS[id].name` fills for a native
202
- * harness, so `agents view` can print custom and native harnesses the same way.
202
+ * Curated vendor/brand display names, matched case-insensitively per token.
203
+ * Entries with a space (e.g. 'Moonshot AI') are single-token → multi-word expansions.
204
+ */
205
+ const VENDOR_TABLE = [
206
+ ['deepseek', 'DeepSeek'],
207
+ ['openai', 'OpenAI'],
208
+ ['anthropic', 'Anthropic'],
209
+ ['claude', 'Claude'],
210
+ ['grok', 'Grok'],
211
+ ['xai', 'xAI'],
212
+ ['gpt', 'GPT'],
213
+ ['meta', 'Meta'],
214
+ ['mistral', 'Mistral'],
215
+ ['mistralai', 'Mistral'],
216
+ ['qwen', 'Qwen'],
217
+ ['gemini', 'Gemini'],
218
+ ['moonshot', 'Moonshot AI'],
219
+ ['moonshotai', 'Moonshot AI'],
220
+ ['kimi', 'Kimi'],
221
+ ['cohere', 'Cohere'],
222
+ ['perplexity', 'Perplexity'],
223
+ ];
224
+ function tokenToDisplayName(token) {
225
+ const lower = token.toLowerCase();
226
+ for (const [key, display] of VENDOR_TABLE) {
227
+ if (lower === key)
228
+ return display;
229
+ }
230
+ return token.charAt(0).toUpperCase() + token.slice(1);
231
+ }
232
+ /**
233
+ * Header label for the harness — derived from `profile.name` by splitting on
234
+ * `[-_]` and mapping each token through the vendor/brand table. Never reads
235
+ * the stored `label` field; old YAML files with a `label:` key are unaffected.
236
+ *
237
+ * Examples: `deepseek-flash` → `'DeepSeek Flash'`, `spark` → `'Spark'`,
238
+ * `deepseek_chat_v3` → `'DeepSeek Chat V3'`.
203
239
  */
204
240
  export function profileLabel(profile) {
205
- return profile.label || profile.name;
241
+ return profile.name.split(/[-_]/).map(tokenToDisplayName).join(' ');
206
242
  }
207
243
  /** Build a stable, machine-readable summary for list and view surfaces. */
208
244
  export function profileSummary(profile) {
@@ -299,8 +335,6 @@ export function profileFromHostModel(name, host, model, opts = {}) {
299
335
  provider: opts.provider ?? host,
300
336
  forkedFrom: host,
301
337
  };
302
- if (opts.label)
303
- profile.label = opts.label;
304
338
  if (opts.provider && opts.authEnvVar) {
305
339
  profile.auth = { envVar: opts.authEnvVar, keychainItem: keychainItemName(opts.provider) };
306
340
  profile.authOptional = false;
@@ -336,14 +370,6 @@ export function forkProfile(source, name, opts = {}) {
336
370
  description: opts.description ?? (opts.model ? `Forked from ${source.name}: ${opts.model}` : source.description),
337
371
  forkedFrom: source.name,
338
372
  };
339
- // `label` is the header `agents view` prints, so an inherited one would make
340
- // the fork and its source visually identical — the ambiguity a per-harness
341
- // block exists to remove. A fork carries a label only when it is given one;
342
- // otherwise `profileLabel` falls back to the fork's own name.
343
- if (opts.label)
344
- forked.label = opts.label;
345
- else
346
- delete forked.label;
347
373
  // A fork that repoints the model or endpoint is no longer that preset — keep
348
374
  // the preset link only while the fork still matches what the preset defines.
349
375
  if (opts.model || opts.baseUrl)
@@ -359,6 +385,51 @@ export function forkProfile(source, name, opts = {}) {
359
385
  }
360
386
  return forked;
361
387
  }
388
+ /**
389
+ * Edit an existing profile in-place, applying overrides without changing its
390
+ * name or lineage. Reuses {@link forkProfile}'s validation and override logic
391
+ * (model swap, base-URL validation, auth repoint), then restores the original
392
+ * `forkedFrom` so an edit never self-references the profile.
393
+ *
394
+ * Note: this returns the updated `Profile` object but does NOT write it to
395
+ * disk — callers should follow up with `writeProfile(result)` if persistence
396
+ * is needed.
397
+ */
398
+ export function editProfile(source, opts = {}) {
399
+ const edited = forkProfile(source, source.name, opts);
400
+ // forkProfile sets forkedFrom = source.name; for an in-place edit that would
401
+ // be a self-reference. Restore the original lineage instead.
402
+ edited.forkedFrom = source.forkedFrom;
403
+ return edited;
404
+ }
405
+ /**
406
+ * Rename a profile on disk, then rewrite `forkedFrom` in every other profile
407
+ * that pointed at the old name so lineage display never goes stale.
408
+ *
409
+ * Throws if `oldName` does not exist or `newName` already exists. There is no
410
+ * `--force` / overwrite path — a collision is a hard error directing the user
411
+ * to remove the target first.
412
+ */
413
+ export function renameProfile(oldName, newName) {
414
+ validateProfileName(newName);
415
+ if (!profileExists(oldName)) {
416
+ throw new Error(`Profile '${oldName}' not found.`);
417
+ }
418
+ if (profileExists(newName)) {
419
+ throw new Error(`Profile '${newName}' already exists; remove it first.`);
420
+ }
421
+ const profile = readProfile(oldName);
422
+ profile.name = newName;
423
+ writeProfile(profile);
424
+ deleteProfile(oldName);
425
+ // Rewrite forkedFrom in every other profile that referenced the old name.
426
+ for (const other of listProfiles()) {
427
+ if (other.name !== newName && other.forkedFrom === oldName) {
428
+ other.forkedFrom = newName;
429
+ writeProfile(other);
430
+ }
431
+ }
432
+ }
362
433
  /**
363
434
  * Resolve a profile into the env block that should be injected into the
364
435
  * spawned agent process. Reads the token from keychain at exec time so the
@@ -378,17 +449,45 @@ export function resolveProfileEnv(profile) {
378
449
  }
379
450
  return env;
380
451
  }
452
+ /**
453
+ * Resolve a requested cost tier against a profile's `models:` map. An unset
454
+ * tier clamps to the next CHEAPER tier that IS set (ultra -> best -> default
455
+ * -> cheap), mirroring the clamp semantics of `bucketRungs` in
456
+ * model-tiers.ts. Returns null when the profile declares no `models:` at all,
457
+ * or none of the tiers at-or-below the request are set.
458
+ */
459
+ function resolveProfileTierModel(profile, tier) {
460
+ if (!profile.models)
461
+ return null;
462
+ const idx = MODEL_TIERS.indexOf(tier);
463
+ for (let i = idx; i >= 0; i--) {
464
+ const rung = MODEL_TIERS[i];
465
+ const model = profile.models[rung];
466
+ if (model)
467
+ return { model, clampedFrom: rung === tier ? undefined : rung };
468
+ }
469
+ return null;
470
+ }
381
471
  /**
382
472
  * Resolve a name into (agent, version, env). Throws if the name is not a
383
473
  * profile. Callers are expected to try agent-id resolution first and fall
384
474
  * back to this when that fails, so we don't need a "isProfile" probe.
475
+ *
476
+ * `requestedModel` is the caller's raw `--model` value. When it is a cost-tier
477
+ * token (`cheap`/`default`/`best`/`ultra`), it is resolved against the
478
+ * profile's OWN `models:` map (see `resolveProfileTierModel`) and substituted
479
+ * into `env` as a concrete model id BEFORE returning — so exec.ts's native
480
+ * tier-resolution block (which indexes the HOST agent's catalog, e.g. Claude's
481
+ * own models) never sees a tier token for a profile-based run, and can't
482
+ * collide the profile's harness identity with its host's catalog.
385
483
  */
386
- export function resolveProfileForRun(name) {
484
+ export function resolveProfileForRun(name, requestedModel) {
387
485
  const profile = readProfile(name);
486
+ const env = resolveProfileEnv(profile);
388
487
  const resolved = {
389
488
  agent: profile.host.agent,
390
489
  version: profile.host.version,
391
- env: resolveProfileEnv(profile),
490
+ env,
392
491
  profileName: profile.name,
393
492
  };
394
493
  if (profile.fallback_model) {
@@ -397,6 +496,25 @@ export function resolveProfileForRun(name) {
397
496
  resolved.fallbackModel = { envKey, model: profile.fallback_model };
398
497
  }
399
498
  }
499
+ if (isTierToken(requestedModel)) {
500
+ const tierPick = resolveProfileTierModel(profile, requestedModel);
501
+ if (tierPick) {
502
+ const envKey = profileModelEnvKey(profile) ?? modelEnvKeyForHost(profile.host.agent);
503
+ env[envKey] = tierPick.model;
504
+ resolved.resolvedModel = tierPick.model;
505
+ // Mirror the native-harness tier block (lib/exec.ts's resolveTier callers):
506
+ // a clamp is always announced, never silent, so a user asking for "ultra"
507
+ // on a harness that only configures "best" knows what it actually got.
508
+ if (tierPick.clampedFrom) {
509
+ resolved.tierNote = `no "${requestedModel}" model configured on profile '${profile.name}'; using its "${tierPick.clampedFrom}" tier (${tierPick.model})`;
510
+ }
511
+ }
512
+ // No `models:` opt-in at all, or no rung to clamp to: leave `env` and
513
+ // `requestedModel` untouched. `agents commands/exec.ts`'s own profile-tier
514
+ // guard (the "cost tiers don't apply to profile ..." discard) still sees
515
+ // the raw tier token downstream and handles the message -- this function
516
+ // doesn't compete with that canonical fallback for the no-opt-in case.
517
+ }
400
518
  return resolved;
401
519
  }
402
520
  /**
@@ -40,3 +40,12 @@ export declare function rankFocusAreas(files: string[], limit?: number): FocusAr
40
40
  * the user's last fetch, which is the correct trade for a read-only card.
41
41
  */
42
42
  export declare function readFocusAreas(root: string, windowDays: number): Promise<FocusArea[]>;
43
+ /** Compact count: 2329 → "2.3k", under 1000 stays exact. */
44
+ export declare function formatFocusCount(n: number): string;
45
+ /**
46
+ * One scannable focus line: path + count, with a single unit trailer so the
47
+ * bare integer is never mistaken for commits or minutes.
48
+ *
49
+ * apps/cli/src 2.3k · apps/cli/docs 302 · apps/factory/src 245 file-touches (7d)
50
+ */
51
+ export declare function formatFocusAreas(areas: FocusArea[], windowDays: number): string;
@@ -78,3 +78,26 @@ export async function readFocusAreas(root, windowDays) {
78
78
  return [];
79
79
  }
80
80
  }
81
+ /** Compact count: 2329 → "2.3k", under 1000 stays exact. */
82
+ export function formatFocusCount(n) {
83
+ if (!Number.isFinite(n) || n < 0)
84
+ return '0';
85
+ if (n < 1000)
86
+ return String(Math.round(n));
87
+ const k = n / 1000;
88
+ const s = k >= 10 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, '');
89
+ return `${s}k`;
90
+ }
91
+ /**
92
+ * One scannable focus line: path + count, with a single unit trailer so the
93
+ * bare integer is never mistaken for commits or minutes.
94
+ *
95
+ * apps/cli/src 2.3k · apps/cli/docs 302 · apps/factory/src 245 file-touches (7d)
96
+ */
97
+ export function formatFocusAreas(areas, windowDays) {
98
+ if (areas.length === 0)
99
+ return '';
100
+ const body = areas.map((a) => `${a.path} ${formatFocusCount(a.touches)}`).join(' · ');
101
+ const unit = `file-touches (${windowDays}d)`;
102
+ return `${body} ${unit}`;
103
+ }
@@ -2,7 +2,7 @@
2
2
  * The one worktree-aware cwd -> project fold.
3
3
  *
4
4
  * Several surfaces bucket work "by project": the `agents sessions` overview,
5
- * the `agents activity` timeline, and anything else that has a cwd and needs a
5
+ * the `agents feed` timeline, and anything else that has a cwd and needs a
6
6
  * stable repo-level key. They must agree, or the same session shows up under
7
7
  * `agents-cli` in one view and `my-branch-slug` in another — so the rule lives
8
8
  * here and every caller delegates.
@@ -2,7 +2,7 @@
2
2
  * The one worktree-aware cwd -> project fold.
3
3
  *
4
4
  * Several surfaces bucket work "by project": the `agents sessions` overview,
5
- * the `agents activity` timeline, and anything else that has a cwd and needs a
5
+ * the `agents feed` timeline, and anything else that has a cwd and needs a
6
6
  * stable repo-level key. They must agree, or the same session shows up under
7
7
  * `agents-cli` in one view and `my-branch-slug` in another — so the rule lives
8
8
  * here and every caller delegates.
@@ -73,3 +73,21 @@ export declare function formatWorkspaceLine(s: RepoWorkspaceStatus): string;
73
73
  * `fleet` row label.
74
74
  */
75
75
  export declare function formatFleetWorkspaces(statuses: HostWorkspaceStatus[]): string[];
76
+ /** Severity for a workspace/repo warning on the project card. */
77
+ export type WorkspaceWarningSeverity = 'critical' | 'continue';
78
+ export interface WorkspaceWarning {
79
+ severity: WorkspaceWarningSeverity;
80
+ text: string;
81
+ remediation?: string;
82
+ }
83
+ /**
84
+ * Turn probed workspace rows into card-footer warnings.
85
+ *
86
+ * - missing / unreadable git → critical (agents there cannot share a tree)
87
+ * - behind upstream → critical when ≥10 commits, continue otherwise
88
+ * - dirty tree → continue (local work is fine; just note it)
89
+ * - ahead-only is not a warning (that is progress waiting to push)
90
+ *
91
+ * Pure. Caller decides whether the rows came from `--fleet` or a local probe.
92
+ */
93
+ export declare function workspaceWarnings(statuses: HostWorkspaceStatus[]): WorkspaceWarning[];
@@ -158,3 +158,49 @@ export function formatFleetWorkspaces(statuses) {
158
158
  return multi ? `${chalk.dim(`${p} · `)}${body}` : body;
159
159
  });
160
160
  }
161
+ /**
162
+ * Turn probed workspace rows into card-footer warnings.
163
+ *
164
+ * - missing / unreadable git → critical (agents there cannot share a tree)
165
+ * - behind upstream → critical when ≥10 commits, continue otherwise
166
+ * - dirty tree → continue (local work is fine; just note it)
167
+ * - ahead-only is not a warning (that is progress waiting to push)
168
+ *
169
+ * Pure. Caller decides whether the rows came from `--fleet` or a local probe.
170
+ */
171
+ export function workspaceWarnings(statuses) {
172
+ const out = [];
173
+ for (const s of [...statuses].sort((a, b) => a.host.localeCompare(b.host) || a.path.localeCompare(b.path))) {
174
+ const where = s.host ? `${s.host}` : 'local';
175
+ const pathBit = s.path ? ` (${s.path})` : '';
176
+ if (!s.present) {
177
+ out.push({
178
+ severity: 'critical',
179
+ text: `${where}: checkout missing${pathBit}`,
180
+ remediation: 'clone or sync the project root on that host before landing agents there',
181
+ });
182
+ continue;
183
+ }
184
+ if (s.error) {
185
+ out.push({
186
+ severity: 'critical',
187
+ text: `${where}: ${s.error}${pathBit}`,
188
+ });
189
+ continue;
190
+ }
191
+ if (s.behind !== undefined && s.behind > 0) {
192
+ out.push({
193
+ severity: s.behind >= 10 ? 'critical' : 'continue',
194
+ text: `${where} is ${s.behind} commit${s.behind === 1 ? '' : 's'} behind ${s.upstream ?? 'upstream'}${pathBit}`,
195
+ remediation: 'pull (or rebase) before agents on this host open PRs against a stale base',
196
+ });
197
+ }
198
+ if (s.dirty !== undefined && s.dirty > 0) {
199
+ out.push({
200
+ severity: 'continue',
201
+ text: `${where} has ${s.dirty} uncommitted change${s.dirty === 1 ? '' : 's'}${pathBit}`,
202
+ });
203
+ }
204
+ }
205
+ return out;
206
+ }
@@ -53,6 +53,13 @@ export interface ProjectSessionRollup {
53
53
  * containing only projects with at least one matched session — callers merge
54
54
  * with the full definition list to show zero-agent projects.
55
55
  */
56
+ /**
57
+ * Ensure every session carries a host for the card roster. Local
58
+ * `getActiveSessions()` omits `machine`; remotes already set it. Pure.
59
+ */
60
+ export declare function withDefaultMachine<T extends {
61
+ machine?: string;
62
+ }>(sessions: T[], defaultHost: string): T[];
56
63
  export declare function rollupSessionsByProject(defs: ProjectDef[], sessions: ActiveSession[]): Map<string, ProjectSessionRollup>;
57
64
  /**
58
65
  * True when a session's status means it is over. Exported so the card can keep
@@ -84,6 +91,19 @@ export declare function liveDeadSplit(byStatus: Partial<Record<ActiveStatus, num
84
91
  export declare function sortProjectMembers(members: ProjectMember[]): ProjectMember[];
85
92
  /** Cap for the members line before it collapses to `+N more`. */
86
93
  export declare const MEMBERS_LINE_LIMIT = 6;
94
+ /** Cap for host groups on the multi-line agents roster. */
95
+ export declare const MEMBERS_HOST_LIMIT = 8;
96
+ /**
97
+ * Collapse members into distinct state cells (`agent · status · ticket[@host]`),
98
+ * counting duplicates as `×N`. Pure.
99
+ */
100
+ export declare function collapseMemberCells(members: ProjectMember[], opts?: {
101
+ includeHostOnCell?: boolean;
102
+ }): Array<{
103
+ cell: string;
104
+ n: number;
105
+ members: number;
106
+ }>;
87
107
  /**
88
108
  * The `agents` line under `live`: one cell per DISTINCT member state —
89
109
  * `claude · running · RUSH-2107 @zion` — with identical cells collapsed to a
@@ -91,8 +111,44 @@ export declare const MEMBERS_LINE_LIMIT = 6;
91
111
  * truncated duplicates), capped at {@link MEMBERS_LINE_LIMIT} cells with a
92
112
  * `+N more` tail counting members, not cells. Pure (chalk styling only); the
93
113
  * caller adds the label.
114
+ *
115
+ * Prefer {@link formatProjectMembersByHost} on the card: a flat line hides which
116
+ * machine is running the work when the same harness×status spans hosts.
94
117
  */
95
118
  export declare function formatProjectMembers(members: ProjectMember[], limit?: number): string;
119
+ /**
120
+ * Host-grouped agents roster. One content line per host:
121
+ * `@zion claude · running ×9 · claude · idle ×4`
122
+ *
123
+ * When no member carries a host (local-only rollup with no machine stamp), falls
124
+ * back to a single flat line via {@link formatProjectMembers}. Pure.
125
+ */
126
+ export declare function formatProjectMembersByHost(members: ProjectMember[], opts?: {
127
+ cellLimit?: number;
128
+ hostLimit?: number;
129
+ }): string[];
130
+ /** A card-level warning collected for the footer. */
131
+ export type ProjectWarningSeverity = 'critical' | 'continue';
132
+ export interface ProjectWarning {
133
+ severity: ProjectWarningSeverity;
134
+ /** One human line. */
135
+ text: string;
136
+ /** Optional fix or next step. */
137
+ remediation?: string;
138
+ }
139
+ /**
140
+ * Severity markers for the warnings footer. User-facing by design: critical
141
+ * stops you (wrong repo, missing checkout, large drift); continue is a soft
142
+ * nudge (dirty tree, schedule not measurable).
143
+ */
144
+ export declare function warningEmoji(severity: ProjectWarningSeverity): string;
145
+ /** Stable sort: critical first, then continue; stable within a tier. */
146
+ export declare function sortProjectWarnings(warnings: ProjectWarning[]): ProjectWarning[];
147
+ /**
148
+ * Format one or more warning lines for the card footer. Pure (chalk only).
149
+ * Returns empty when there is nothing to say.
150
+ */
151
+ export declare function formatProjectWarnings(warnings: ProjectWarning[]): string[];
96
152
  /** Harvested signals not on the session list: repo-global merged PRs + releases, local artifacts, in a time window. */
97
153
  export interface ProjectRemoteSignals {
98
154
  windowDays: number;
@@ -41,6 +41,13 @@ function blank(name) {
41
41
  * containing only projects with at least one matched session — callers merge
42
42
  * with the full definition list to show zero-agent projects.
43
43
  */
44
+ /**
45
+ * Ensure every session carries a host for the card roster. Local
46
+ * `getActiveSessions()` omits `machine`; remotes already set it. Pure.
47
+ */
48
+ export function withDefaultMachine(sessions, defaultHost) {
49
+ return sessions.map((s) => (s.machine ? s : { ...s, machine: defaultHost }));
50
+ }
44
51
  export function rollupSessionsByProject(defs, sessions) {
45
52
  const map = new Map();
46
53
  const prSeen = new Map();
@@ -148,25 +155,20 @@ export function sortProjectMembers(members) {
148
155
  }
149
156
  /** Cap for the members line before it collapses to `+N more`. */
150
157
  export const MEMBERS_LINE_LIMIT = 6;
158
+ /** Cap for host groups on the multi-line agents roster. */
159
+ export const MEMBERS_HOST_LIMIT = 8;
151
160
  /**
152
- * The `agents` line under `live`: one cell per DISTINCT member state —
153
- * `claude · running · RUSH-2107 @zion` — with identical cells collapsed to a
154
- * `×N` count (35 same-harness sessions in one state are one fact, not six
155
- * truncated duplicates), capped at {@link MEMBERS_LINE_LIMIT} cells with a
156
- * `+N more` tail counting members, not cells. Pure (chalk styling only); the
157
- * caller adds the label.
161
+ * Collapse members into distinct state cells (`agent · status · ticket[@host]`),
162
+ * counting duplicates as `×N`. Pure.
158
163
  */
159
- export function formatProjectMembers(members, limit = MEMBERS_LINE_LIMIT) {
160
- if (members.length === 0)
161
- return '';
162
- // Collapse identical cells — 35 same-harness sessions in the same state are
163
- // one fact (`claude · running ×16`), not six truncated duplicates.
164
+ export function collapseMemberCells(members, opts = {}) {
165
+ const includeHost = opts.includeHostOnCell !== false;
164
166
  const counts = new Map();
165
167
  for (const m of sortProjectMembers(members)) {
166
168
  const parts = [m.agent, m.status];
167
169
  if (m.ticket)
168
170
  parts.push(m.ticket);
169
- const cell = parts.join(' · ') + (m.host ? ` @${m.host}` : '');
171
+ const cell = parts.join(' · ') + (includeHost && m.host ? ` @${m.host}` : '');
170
172
  const key = cell.toLowerCase();
171
173
  const entry = counts.get(key);
172
174
  if (entry)
@@ -174,12 +176,117 @@ export function formatProjectMembers(members, limit = MEMBERS_LINE_LIMIT) {
174
176
  else
175
177
  counts.set(key, { cell, n: 1 });
176
178
  }
177
- const entries = [...counts.values()];
178
- const shown = entries.slice(0, Math.max(1, limit));
179
- const shownMembers = shown.reduce((acc, e) => acc + e.n, 0);
180
- const more = members.length - shownMembers;
181
- const cells = shown.map(({ cell, n }) => (n > 1 ? `${cell} ×${n}` : cell));
182
- return cells.join(chalk.dim(' · ')) + (more > 0 ? chalk.dim(` · +${more} more`) : '');
179
+ return [...counts.values()].map(({ cell, n }) => ({ cell, n, members: n }));
180
+ }
181
+ function formatCollapsedCells(cells, memberTotal, limit) {
182
+ if (cells.length === 0)
183
+ return '';
184
+ const shown = cells.slice(0, Math.max(1, limit));
185
+ const shownMembers = shown.reduce((acc, e) => acc + e.members, 0);
186
+ const more = memberTotal - shownMembers;
187
+ const parts = shown.map(({ cell, n }) => (n > 1 ? `${cell} ×${n}` : cell));
188
+ return parts.join(chalk.dim(' · ')) + (more > 0 ? chalk.dim(` · +${more} more`) : '');
189
+ }
190
+ /**
191
+ * The `agents` line under `live`: one cell per DISTINCT member state —
192
+ * `claude · running · RUSH-2107 @zion` — with identical cells collapsed to a
193
+ * `×N` count (35 same-harness sessions in one state are one fact, not six
194
+ * truncated duplicates), capped at {@link MEMBERS_LINE_LIMIT} cells with a
195
+ * `+N more` tail counting members, not cells. Pure (chalk styling only); the
196
+ * caller adds the label.
197
+ *
198
+ * Prefer {@link formatProjectMembersByHost} on the card: a flat line hides which
199
+ * machine is running the work when the same harness×status spans hosts.
200
+ */
201
+ export function formatProjectMembers(members, limit = MEMBERS_LINE_LIMIT) {
202
+ if (members.length === 0)
203
+ return '';
204
+ return formatCollapsedCells(collapseMemberCells(members, { includeHostOnCell: true }), members.length, limit);
205
+ }
206
+ /**
207
+ * Host-grouped agents roster. One content line per host:
208
+ * `@zion claude · running ×9 · claude · idle ×4`
209
+ *
210
+ * When no member carries a host (local-only rollup with no machine stamp), falls
211
+ * back to a single flat line via {@link formatProjectMembers}. Pure.
212
+ */
213
+ export function formatProjectMembersByHost(members, opts = {}) {
214
+ if (members.length === 0)
215
+ return [];
216
+ const cellLimit = opts.cellLimit ?? MEMBERS_LINE_LIMIT;
217
+ const hostLimit = opts.hostLimit ?? MEMBERS_HOST_LIMIT;
218
+ const byHost = new Map();
219
+ let anyHost = false;
220
+ for (const m of members) {
221
+ if (m.host)
222
+ anyHost = true;
223
+ const key = m.host ?? '';
224
+ const list = byHost.get(key);
225
+ if (list)
226
+ list.push(m);
227
+ else
228
+ byHost.set(key, [m]);
229
+ }
230
+ // Local-only (no host stamps at all) — keep the compact one-liner.
231
+ if (!anyHost) {
232
+ const line = formatProjectMembers(members, cellLimit);
233
+ return line ? [line] : [];
234
+ }
235
+ // Hosts with the most members first, then name; unstamped ("") last.
236
+ const hosts = [...byHost.entries()].sort((a, b) => {
237
+ if (a[0] === '' && b[0] !== '')
238
+ return 1;
239
+ if (b[0] === '' && a[0] !== '')
240
+ return -1;
241
+ if (b[1].length !== a[1].length)
242
+ return b[1].length - a[1].length;
243
+ return a[0].localeCompare(b[0]);
244
+ });
245
+ const shownHosts = hosts.slice(0, Math.max(1, hostLimit));
246
+ const hiddenMembers = hosts.slice(hostLimit).reduce((acc, [, ms]) => acc + ms.length, 0);
247
+ const hostWidth = Math.max(...shownHosts.map(([h]) => (h ? `@${h}` : '@local').length), 1);
248
+ const lines = shownHosts.map(([host, ms]) => {
249
+ const label = (host ? `@${host}` : '@local').padEnd(hostWidth);
250
+ // Host is the row key — do not repeat @host on every cell.
251
+ const cells = collapseMemberCells(ms, { includeHostOnCell: false });
252
+ const body = formatCollapsedCells(cells, ms.length, cellLimit);
253
+ return `${chalk.cyan(label)} ${body}`;
254
+ });
255
+ if (hiddenMembers > 0) {
256
+ const restHosts = hosts.length - shownHosts.length;
257
+ lines.push(chalk.dim(`+${hiddenMembers} more on ${restHosts} host${restHosts === 1 ? '' : 's'}`));
258
+ }
259
+ return lines;
260
+ }
261
+ /**
262
+ * Severity markers for the warnings footer. User-facing by design: critical
263
+ * stops you (wrong repo, missing checkout, large drift); continue is a soft
264
+ * nudge (dirty tree, schedule not measurable).
265
+ */
266
+ export function warningEmoji(severity) {
267
+ return severity === 'critical' ? '🔴' : '⚠️';
268
+ }
269
+ /** Stable sort: critical first, then continue; stable within a tier. */
270
+ export function sortProjectWarnings(warnings) {
271
+ const rank = { critical: 0, continue: 1 };
272
+ return [...warnings].sort((a, b) => rank[a.severity] - rank[b.severity] || a.text.localeCompare(b.text));
273
+ }
274
+ /**
275
+ * Format one or more warning lines for the card footer. Pure (chalk only).
276
+ * Returns empty when there is nothing to say.
277
+ */
278
+ export function formatProjectWarnings(warnings) {
279
+ if (warnings.length === 0)
280
+ return [];
281
+ const lines = [];
282
+ for (const w of sortProjectWarnings(warnings)) {
283
+ const mark = warningEmoji(w.severity);
284
+ const color = w.severity === 'critical' ? chalk.red : chalk.yellow;
285
+ lines.push(` ${mark} ${color(w.text)}`);
286
+ if (w.remediation)
287
+ lines.push(` ${chalk.dim(w.remediation)}`);
288
+ }
289
+ return lines;
183
290
  }
184
291
  /**
185
292
  * Harvest the signals that don't live on the active-session list: recently
@@ -124,6 +124,9 @@ export function getMcpConfigPath(agent, versionHome) {
124
124
  return path.join(versionHome, '.grok', 'mcp.json');
125
125
  case 'hermes':
126
126
  return path.join(versionHome, '.hermes', 'config.yaml');
127
+ case 'pi':
128
+ // omp reads user-scope MCP from ~/.omp/agent/.mcp.json (Claude schema).
129
+ return path.join(versionHome, '.omp', 'agent', '.mcp.json');
127
130
  default:
128
131
  return null;
129
132
  }
@@ -5,7 +5,7 @@
5
5
  * - Union: All resources from all layers are combined
6
6
  * - Override on name conflict: Higher layer wins (project > user > system)
7
7
  */
8
- export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes';
8
+ export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'pi';
9
9
  export type Layer = 'system' | 'user' | 'project';
10
10
  export type ResourceKind = 'command' | 'hook' | 'skill' | 'rule' | 'mcp' | 'permission' | 'subagent' | 'workflow' | 'memory';
11
11
  /** A resolved resource with its origin layer. */
@@ -8,6 +8,7 @@ import type { AgentId, RunStrategy } from './types.js';
8
8
  import type { FallbackEntry } from './exec.js';
9
9
  import { type AccountInfo, type CredentialPresence } from './agents.js';
10
10
  import { type UsageSnapshot } from './usage.js';
11
+ import { type AuthVerdict } from './auth-health.js';
11
12
  export interface RotateCandidate {
12
13
  agent: AgentId;
13
14
  version: string;
@@ -35,6 +36,18 @@ export interface RotateCandidate {
35
36
  usageMinutesToLimit: number | null;
36
37
  plan: string | null;
37
38
  signedIn: boolean;
39
+ /**
40
+ * Live auth-health verdict for this (agent, version) from the daemon's probe
41
+ * cache (`auth-health.ts`), or null when no probe row exists (cold cache, or a
42
+ * harness with no live-probe endpoint). `signedIn` only means "a credential
43
+ * file is present and its email decodes" — it cannot tell a good token from a
44
+ * revoked-but-unexpired one, so a server-rejected account reads
45
+ * `signedIn: true` but `authVerdict: 'revoked'`. Eligibility excludes a
46
+ * revoked account so rotation never launches into a doomed auth (see
47
+ * {@link readinessFromCandidate}). Fail-open: any non-revoked or null verdict
48
+ * does not gate — a stale/absent probe never blocks a launch.
49
+ */
50
+ authVerdict: AuthVerdict | null;
38
51
  lastActive: Date | null;
39
52
  }
40
53
  export interface RotateResult {
@@ -108,15 +121,17 @@ export declare const USAGE_DECISION_MAX_AGE_MS: number;
108
121
  export declare function isUsageVerified(candidate: RotateCandidate, nowMs?: number): boolean;
109
122
  /**
110
123
  * Whether a specific account can serve a run right now, and — when it can't —
111
- * why. `signed_out` covers a missing usable credential; `rate_limited` and
112
- * `out_of_credits` name the throttle. Used to pre-warn on a version-pinned
113
- * teammate whose account rotation won't route around (a pin IS the target).
124
+ * why. `signed_out` covers a missing usable credential; `revoked` is a token the
125
+ * server has actually rejected (401/403, from the live auth-health probe);
126
+ * `rate_limited` and `out_of_credits` name the throttle. Used to pre-warn on a
127
+ * version-pinned teammate whose account rotation won't route around (a pin IS
128
+ * the target).
114
129
  */
115
130
  export type AccountReadiness = {
116
131
  ready: true;
117
132
  } | {
118
133
  ready: false;
119
- reason: 'rate_limited' | 'out_of_credits' | 'signed_out';
134
+ reason: 'rate_limited' | 'out_of_credits' | 'signed_out' | 'revoked';
120
135
  email: string | null;
121
136
  };
122
137
  /**