@phnx-labs/agents-cli 1.20.92 → 1.21.0

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 (146) hide show
  1. package/CHANGELOG.md +385 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/beta.js +3 -5
  5. package/dist/commands/doctor.js +23 -0
  6. package/dist/commands/events.js +91 -1
  7. package/dist/commands/exec.js +13 -0
  8. package/dist/commands/mailboxes.js +39 -1
  9. package/dist/commands/message.js +12 -1
  10. package/dist/commands/packages.js +14 -5
  11. package/dist/commands/projects.d.ts +38 -0
  12. package/dist/commands/projects.js +355 -48
  13. package/dist/commands/routines.js +27 -1
  14. package/dist/commands/rules.js +6 -0
  15. package/dist/commands/secrets.d.ts +17 -0
  16. package/dist/commands/secrets.js +198 -7
  17. package/dist/commands/send.d.ts +14 -12
  18. package/dist/commands/send.js +105 -35
  19. package/dist/commands/sessions.d.ts +17 -0
  20. package/dist/commands/sessions.js +16 -8
  21. package/dist/commands/setup-browser.js +5 -1
  22. package/dist/commands/setup-fleet.js +5 -0
  23. package/dist/commands/setup-preferences.d.ts +53 -0
  24. package/dist/commands/setup-preferences.js +142 -0
  25. package/dist/commands/setup.js +8 -9
  26. package/dist/commands/ssh.js +220 -10
  27. package/dist/commands/sync.d.ts +2 -2
  28. package/dist/commands/sync.js +13 -12
  29. package/dist/commands/view.js +4 -0
  30. package/dist/commands/watchdog.js +26 -3
  31. package/dist/index.js +38 -6
  32. package/dist/lib/activity.d.ts +15 -0
  33. package/dist/lib/activity.js +26 -2
  34. package/dist/lib/auto-dispatch.d.ts +6 -1
  35. package/dist/lib/auto-dispatch.js +7 -2
  36. package/dist/lib/beta.d.ts +1 -1
  37. package/dist/lib/beta.js +1 -1
  38. package/dist/lib/channels/send.d.ts +83 -0
  39. package/dist/lib/channels/send.js +112 -0
  40. package/dist/lib/daemon.d.ts +13 -0
  41. package/dist/lib/daemon.js +80 -60
  42. package/dist/lib/device-config.d.ts +82 -0
  43. package/dist/lib/device-config.js +296 -0
  44. package/dist/lib/event-provenance.d.ts +19 -0
  45. package/dist/lib/event-provenance.js +48 -0
  46. package/dist/lib/events-ingest.d.ts +46 -0
  47. package/dist/lib/events-ingest.js +182 -0
  48. package/dist/lib/events.d.ts +17 -5
  49. package/dist/lib/events.js +59 -56
  50. package/dist/lib/feed.d.ts +1 -1
  51. package/dist/lib/feed.js +2 -0
  52. package/dist/lib/git.d.ts +13 -2
  53. package/dist/lib/git.js +38 -6
  54. package/dist/lib/linear-project-counts.d.ts +129 -0
  55. package/dist/lib/linear-project-counts.js +206 -0
  56. package/dist/lib/linear-projects.d.ts +62 -0
  57. package/dist/lib/linear-projects.js +144 -0
  58. package/dist/lib/mailbox-gc.js +30 -7
  59. package/dist/lib/mailbox.d.ts +14 -1
  60. package/dist/lib/mailbox.js +35 -3
  61. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  63. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  64. package/dist/lib/menubar/install-menubar.js +35 -44
  65. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  66. package/dist/lib/menubar/notify-desktop.js +8 -2
  67. package/dist/lib/migrate.js +1 -1
  68. package/dist/lib/models.js +21 -11
  69. package/dist/lib/overdue.d.ts +1 -1
  70. package/dist/lib/overdue.js +54 -19
  71. package/dist/lib/project-import.d.ts +96 -0
  72. package/dist/lib/project-import.js +171 -0
  73. package/dist/lib/project-probe.d.ts +75 -0
  74. package/dist/lib/project-probe.js +160 -0
  75. package/dist/lib/project-resources.d.ts +8 -0
  76. package/dist/lib/project-resources.js +31 -3
  77. package/dist/lib/project-status.d.ts +32 -1
  78. package/dist/lib/project-status.js +82 -1
  79. package/dist/lib/projects.d.ts +6 -0
  80. package/dist/lib/projects.js +12 -0
  81. package/dist/lib/remote-agents-json.d.ts +14 -1
  82. package/dist/lib/remote-agents-json.js +21 -3
  83. package/dist/lib/routine-notify.d.ts +11 -0
  84. package/dist/lib/routine-notify.js +22 -0
  85. package/dist/lib/routines.d.ts +53 -0
  86. package/dist/lib/routines.js +105 -4
  87. package/dist/lib/rules/run-sync.d.ts +18 -0
  88. package/dist/lib/rules/run-sync.js +92 -0
  89. package/dist/lib/run-notify.js +3 -0
  90. package/dist/lib/scheduler.js +12 -2
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/audit.d.ts +1 -1
  94. package/dist/lib/secrets/audit.js +53 -10
  95. package/dist/lib/secrets/index.d.ts +4 -5
  96. package/dist/lib/secrets/index.js +4 -5
  97. package/dist/lib/secrets/list-filter.d.ts +20 -5
  98. package/dist/lib/secrets/list-filter.js +22 -6
  99. package/dist/lib/secrets/usage-db.d.ts +106 -0
  100. package/dist/lib/secrets/usage-db.js +236 -0
  101. package/dist/lib/session/active.d.ts +16 -2
  102. package/dist/lib/session/active.js +8 -4
  103. package/dist/lib/session/bundle.js +5 -1
  104. package/dist/lib/session/remote-active.d.ts +5 -1
  105. package/dist/lib/session/remote-active.js +4 -1
  106. package/dist/lib/session/remote-list.d.ts +1 -15
  107. package/dist/lib/session/remote-list.js +22 -111
  108. package/dist/lib/session/sync/config.d.ts +13 -5
  109. package/dist/lib/session/sync/config.js +21 -13
  110. package/dist/lib/sqlite.js +28 -1
  111. package/dist/lib/startup/command-registry.d.ts +0 -2
  112. package/dist/lib/startup/command-registry.js +1 -5
  113. package/dist/lib/state.d.ts +19 -2
  114. package/dist/lib/state.js +99 -10
  115. package/dist/lib/sync-umbrella.d.ts +11 -23
  116. package/dist/lib/sync-umbrella.js +14 -45
  117. package/dist/lib/teams/agents.d.ts +35 -3
  118. package/dist/lib/teams/agents.js +52 -8
  119. package/dist/lib/teams/scheduler.d.ts +29 -2
  120. package/dist/lib/teams/scheduler.js +60 -16
  121. package/dist/lib/types.d.ts +22 -5
  122. package/dist/lib/versions.d.ts +6 -0
  123. package/dist/lib/versions.js +6 -4
  124. package/package.json +1 -2
  125. package/dist/commands/drive.d.ts +0 -10
  126. package/dist/commands/drive.js +0 -183
  127. package/dist/commands/hq.d.ts +0 -2
  128. package/dist/commands/hq.js +0 -58
  129. package/dist/commands/sessions-sync.d.ts +0 -17
  130. package/dist/commands/sessions-sync.js +0 -135
  131. package/dist/commands/sync-provision.d.ts +0 -23
  132. package/dist/commands/sync-provision.js +0 -107
  133. package/dist/lib/drive-sync.d.ts +0 -45
  134. package/dist/lib/drive-sync.js +0 -238
  135. package/dist/lib/hq/floor.d.ts +0 -87
  136. package/dist/lib/hq/floor.js +0 -243
  137. package/dist/lib/session/sync/crdt.d.ts +0 -44
  138. package/dist/lib/session/sync/crdt.js +0 -119
  139. package/dist/lib/session/sync/manifest.d.ts +0 -62
  140. package/dist/lib/session/sync/manifest.js +0 -100
  141. package/dist/lib/session/sync/provision.d.ts +0 -49
  142. package/dist/lib/session/sync/provision.js +0 -91
  143. package/dist/lib/session/sync/r2.d.ts +0 -32
  144. package/dist/lib/session/sync/r2.js +0 -121
  145. package/dist/lib/session/sync/sync.d.ts +0 -106
  146. package/dist/lib/session/sync/sync.js +0 -374
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Project workspace probing — the drift signal behind `projects status --fleet`.
3
+ *
4
+ * Projects are natively multi-device: the same definition (home-relative paths)
5
+ * re-roots on every fleet machine, and the question is whether the project's
6
+ * repos are PRESENT on each box, on which branch, how far ahead/behind their
7
+ * upstream, and whether they carry uncommitted changes. This module is the pure
8
+ * local half: given a set of home-relative paths it probes each one with a
9
+ * handful of read-only git calls. Drift is measured against the LAST-FETCHED
10
+ * upstream (`@{upstream}`) — deliberately no `git fetch`, so a probe is fast
11
+ * and offline-safe. The fleet half (`--fleet`) just runs this probe on every
12
+ * peer via the canonical `remote-agents-json` SSH fan-out.
13
+ */
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import { execFileSync } from 'child_process';
17
+ import chalk from 'chalk';
18
+ import { expandLocalHome, toHomeRelative } from './project-root.js';
19
+ /** Per-call git budget. A read-only git call taking >3s is wedged by any
20
+ * definition (NFS stall, index lock) — and the fleet fan-out SIGKILLs the SSH
21
+ * hop at 12s, so a probe must fit inside that budget to avoid a slow peer
22
+ * being misreported as unreachable: 3s × 5 calls leaves headroom even when
23
+ * one repo is genuinely stuck. */
24
+ const GIT_TIMEOUT_MS = 3_000;
25
+ /** One read-only git call against `absPath`; undefined on any failure. */
26
+ function git(absPath, args) {
27
+ try {
28
+ return execFileSync('git', ['-C', absPath, ...args], {
29
+ encoding: 'utf8',
30
+ timeout: GIT_TIMEOUT_MS,
31
+ stdio: ['ignore', 'pipe', 'ignore'],
32
+ }).trim();
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ /**
39
+ * Probe one workspace repo. A missing path yields `{present: false}` and no
40
+ * git call is made. On a present repo every signal is best-effort: whatever
41
+ * succeeded is reported, and a repo whose `.git` exists yet every git call
42
+ * failed surfaces as present-with-error rather than silently clean.
43
+ */
44
+ export function probeRepoWorkspace(absPath) {
45
+ const status = { path: toHomeRelative(absPath), present: false };
46
+ if (!fs.existsSync(path.join(absPath, '.git')))
47
+ return status;
48
+ status.present = true;
49
+ const branch = git(absPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
50
+ const upstream = git(absPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']);
51
+ // `--left-right --count A...B` prints "<left>\t<right>" — left is
52
+ // upstream-only (we are BEHIND by that much), right is HEAD-only (AHEAD).
53
+ const counts = upstream !== undefined
54
+ ? git(absPath, ['rev-list', '--left-right', '--count', '@{upstream}...HEAD'])
55
+ : undefined;
56
+ const dirtyOut = git(absPath, ['status', '--porcelain']);
57
+ const lastCommit = git(absPath, ['log', '-1', '--format=%cI']);
58
+ if (branch === undefined && dirtyOut === undefined && lastCommit === undefined) {
59
+ status.error = '.git exists but git could not read this repo';
60
+ return status;
61
+ }
62
+ if (branch !== undefined)
63
+ status.branch = branch;
64
+ if (upstream !== undefined)
65
+ status.upstream = upstream;
66
+ if (counts !== undefined) {
67
+ const [behind, ahead] = counts.split(/\s+/).map(Number);
68
+ if (Number.isFinite(behind) && Number.isFinite(ahead)) {
69
+ status.behind = behind;
70
+ status.ahead = ahead;
71
+ }
72
+ }
73
+ if (dirtyOut !== undefined)
74
+ status.dirty = dirtyOut === '' ? 0 : dirtyOut.split('\n').length;
75
+ if (lastCommit !== undefined)
76
+ status.lastCommit = lastCommit;
77
+ return status;
78
+ }
79
+ /** Probe each home-relative path (expanded against the local home), in order. */
80
+ export function probeProjectWorkspaces(paths) {
81
+ return paths.map((p) => probeRepoWorkspace(expandLocalHome(p)));
82
+ }
83
+ /**
84
+ * The home-relative paths to probe for a project definition: its `root` plus
85
+ * each `repos[].path` (the opt-in for additional repos), deduped. Every target
86
+ * is normalized through the same `toHomeRelative(expandLocalHome(...))` the
87
+ * probe echoes, so a hand-edited def (absolute path under home, trailing
88
+ * slash) matches its probe rows exactly — `writeProjectDef` normalizes on
89
+ * write, but defs are hand-editable YAML and never silently drop a row.
90
+ */
91
+ export function workspaceTargetsForDef(def) {
92
+ const targets = [def.root, ...(def.repos ?? []).map((r) => r.path)]
93
+ .filter((p) => typeof p === 'string' && p.length > 0)
94
+ .map((p) => toHomeRelative(expandLocalHome(p)));
95
+ return [...new Set(targets)];
96
+ }
97
+ /**
98
+ * Parse a peer's `projects probe --json` stdout, tagging each row with the
99
+ * machine that answered. Defensive against version skew / partial output, the
100
+ * same boundary contract as `parseRemoteActive`: non-JSON or a non-array
101
+ * yields `[]`, and rows without a `path`/`present` core are dropped.
102
+ */
103
+ export function parseRemoteProbe(stdout, machine) {
104
+ let parsed;
105
+ try {
106
+ parsed = JSON.parse(stdout);
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ if (!Array.isArray(parsed))
112
+ return [];
113
+ return parsed.flatMap((x) => {
114
+ if (x && typeof x === 'object' && !Array.isArray(x)) {
115
+ const o = x;
116
+ if (typeof o.path === 'string' && typeof o.present === 'boolean') {
117
+ return [{ ...o, host: machine }];
118
+ }
119
+ }
120
+ return [];
121
+ });
122
+ }
123
+ /**
124
+ * One workspace's compact state: `✓ clean · main`, `⚠ 12 dirty · ↑3 ↓1 ·
125
+ * feature/x`, `✗ missing`, or `⚠ error: …`. Pure — chalk styling only.
126
+ */
127
+ export function formatWorkspaceLine(s) {
128
+ if (!s.present)
129
+ return chalk.red('✗ missing');
130
+ if (s.error)
131
+ return chalk.yellow(`⚠ error: ${s.error}`);
132
+ const parts = [];
133
+ if (s.dirty !== undefined && s.dirty > 0)
134
+ parts.push(`${s.dirty} dirty`);
135
+ const drift = [
136
+ s.ahead !== undefined && s.ahead > 0 ? `↑${s.ahead}` : '',
137
+ s.behind !== undefined && s.behind > 0 ? `↓${s.behind}` : '',
138
+ ].filter(Boolean).join(' ');
139
+ if (drift)
140
+ parts.push(drift);
141
+ const head = parts.length > 0 ? chalk.yellow(`⚠ ${parts.join(' · ')}`) : chalk.green('✓ clean');
142
+ return s.branch ? `${head} ${chalk.dim('·')} ${s.branch}` : head;
143
+ }
144
+ /**
145
+ * The fleet view of one project's workspaces: one content line per probed
146
+ * path (host-sorted `host: state` cells joined by ` · `), labelled with the
147
+ * path when a project probes more than one. Pure — the caller adds the
148
+ * `fleet` row label.
149
+ */
150
+ export function formatFleetWorkspaces(statuses) {
151
+ const paths = [...new Set(statuses.map((s) => s.path))];
152
+ const multi = paths.length > 1;
153
+ return paths.map((p) => {
154
+ const rows = statuses
155
+ .filter((s) => s.path === p)
156
+ .sort((a, b) => a.host.localeCompare(b.host));
157
+ const body = rows.map((r) => `${chalk.cyan(r.host)}: ${formatWorkspaceLine(r)}`).join(chalk.dim(' · '));
158
+ return multi ? `${chalk.dim(`${p} · `)}${body}` : body;
159
+ });
160
+ }
@@ -5,3 +5,11 @@ export interface ProjectResourceSyncResult {
5
5
  }
6
6
  export declare function projectAgentRoot(projectRoot: string, agent: AgentId): string;
7
7
  export declare function syncProjectResourcesToAgent(agent: AgentId, version: string, projectAgentsDir: string): ProjectResourceSyncResult;
8
+ /**
9
+ * One human line for the files a project sync left alone because you already
10
+ * wrote them. This is the normal steady state — every sync of a project whose
11
+ * `.claude/commands/` you hand-authored hits it — so it is a single grouped
12
+ * line, not one wrapped warning per file, and it says "yours" rather than the
13
+ * internal "user-owned". Returns null when nothing was skipped.
14
+ */
15
+ export declare function formatKeptProjectResources(skipped: string[]): string | null;
@@ -127,9 +127,37 @@ function record(kind, name, relPaths, result, manifestPaths) {
127
127
  manifestPaths.add(toPosixRel(rel));
128
128
  }
129
129
  function skip(dest, projectRoot, result) {
130
- const rel = path.relative(projectRoot, dest);
131
- result.skipped.push(rel);
132
- console.warn(`Skipping project resource target ${rel}: already exists and is user-owned`);
130
+ result.skipped.push(path.relative(projectRoot, dest));
131
+ }
132
+ /**
133
+ * One human line for the files a project sync left alone because you already
134
+ * wrote them. This is the normal steady state — every sync of a project whose
135
+ * `.claude/commands/` you hand-authored hits it — so it is a single grouped
136
+ * line, not one wrapped warning per file, and it says "yours" rather than the
137
+ * internal "user-owned". Returns null when nothing was skipped.
138
+ */
139
+ export function formatKeptProjectResources(skipped) {
140
+ if (skipped.length === 0)
141
+ return null;
142
+ const rels = [...skipped].sort((a, b) => a.localeCompare(b)).map(toPosixRel);
143
+ if (rels.length === 1)
144
+ return `Kept your existing ${rels[0]}`;
145
+ const byDir = new Map();
146
+ for (const rel of rels) {
147
+ const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '.';
148
+ const names = byDir.get(dir) ?? [];
149
+ names.push(rel.slice(rel.lastIndexOf('/') + 1));
150
+ byDir.set(dir, names);
151
+ }
152
+ if (byDir.size === 1) {
153
+ const [dir, names] = [...byDir.entries()][0];
154
+ const PREVIEW = 3;
155
+ const preview = names.slice(0, PREVIEW).join(', ');
156
+ const more = names.length > PREVIEW ? `, +${names.length - PREVIEW} more` : '';
157
+ return `Kept ${rels.length} of your own files in ${dir}: ${preview}${more}`;
158
+ }
159
+ const dirs = [...byDir.entries()].map(([dir, names]) => `${dir} (${names.length})`).join(', ');
160
+ return `Kept ${rels.length} of your own files in ${dirs}`;
133
161
  }
134
162
  function syncProjectCommands(agent, version, projectAgentsDir, agentRoot, result, manifestPaths) {
135
163
  const cfg = AGENTS[agent];
@@ -13,6 +13,17 @@
13
13
  */
14
14
  import type { ActiveSession, ActiveStatus } from './session/active.js';
15
15
  import { type ProjectDef } from './projects.js';
16
+ /** One live agent on a project — the WHO behind the byStatus count. */
17
+ export interface ProjectMember {
18
+ /** Harness name (claude / codex / …), from the session's `kind`. */
19
+ agent: string;
20
+ /** Lifecycle status (running / idle / …). */
21
+ status: string;
22
+ /** Tracker ticket the session is tied to, when any. */
23
+ ticket?: string;
24
+ /** Machine the session runs on (provenance host / fleet peer), when known. */
25
+ host?: string;
26
+ }
16
27
  /** One project's live session rollup. */
17
28
  export interface ProjectSessionRollup {
18
29
  name: string;
@@ -20,6 +31,8 @@ export interface ProjectSessionRollup {
20
31
  agents: number;
21
32
  /** Count per lifecycle status. */
22
33
  byStatus: Partial<Record<ActiveStatus, number>>;
34
+ /** Which agents are on the project (one per matched session). */
35
+ members: ProjectMember[];
23
36
  /** Summed checklist progress across this project's sessions. */
24
37
  plan: {
25
38
  done: number;
@@ -46,7 +59,20 @@ export declare function planPct(plan: {
46
59
  done: number;
47
60
  total: number;
48
61
  }): number | undefined;
49
- /** Harvested signals not on the session list: repo-global merged PRs + local artifacts, in a time window. */
62
+ /** Sort members for the card: running first, then idle, then the rest; agent name asc within a state. */
63
+ export declare function sortProjectMembers(members: ProjectMember[]): ProjectMember[];
64
+ /** Cap for the members line before it collapses to `+N more`. */
65
+ export declare const MEMBERS_LINE_LIMIT = 6;
66
+ /**
67
+ * The `agents` line under `live`: one cell per DISTINCT member state —
68
+ * `claude · running · RUSH-2107 @zion` — with identical cells collapsed to a
69
+ * `×N` count (35 same-harness sessions in one state are one fact, not six
70
+ * truncated duplicates), capped at {@link MEMBERS_LINE_LIMIT} cells with a
71
+ * `+N more` tail counting members, not cells. Pure (chalk styling only); the
72
+ * caller adds the label.
73
+ */
74
+ export declare function formatProjectMembers(members: ProjectMember[], limit?: number): string;
75
+ /** Harvested signals not on the session list: repo-global merged PRs + releases, local artifacts, in a time window. */
50
76
  export interface ProjectRemoteSignals {
51
77
  windowDays: number;
52
78
  /** PRs merged into the primary repo within the window (via `gh`). */
@@ -55,6 +81,11 @@ export interface ProjectRemoteSignals {
55
81
  artifacts: number;
56
82
  /** Basename of the most recent artifact, when any. */
57
83
  lastArtifact?: string;
84
+ /** Latest release of the PRIMARY repo (via `gh release list`), when any. */
85
+ latestRelease?: {
86
+ tag: string;
87
+ publishedAt: string;
88
+ };
58
89
  }
59
90
  /**
60
91
  * Harvest the signals that don't live on the active-session list: recently
@@ -13,11 +13,21 @@
13
13
  */
14
14
  import { execFile } from 'child_process';
15
15
  import { promisify } from 'util';
16
+ import chalk from 'chalk';
16
17
  import { projectNameForCwd } from './projects.js';
17
18
  import { readRecentActivity } from './activity.js';
18
19
  const execFileAsync = promisify(execFile);
19
20
  function blank(name) {
20
- return { name, agents: 0, byStatus: {}, plan: { done: 0, total: 0 }, openPrs: [], tickets: [], worktrees: 0 };
21
+ return {
22
+ name,
23
+ agents: 0,
24
+ byStatus: {},
25
+ members: [],
26
+ plan: { done: 0, total: 0 },
27
+ openPrs: [],
28
+ tickets: [],
29
+ worktrees: 0,
30
+ };
21
31
  }
22
32
  /**
23
33
  * Roll active sessions up by project. Returns a map keyed by project name,
@@ -41,6 +51,12 @@ export function rollupSessionsByProject(defs, sessions) {
41
51
  }
42
52
  r.agents++;
43
53
  r.byStatus[s.status] = (r.byStatus[s.status] ?? 0) + 1;
54
+ const member = { agent: s.kind, status: s.status };
55
+ if (s.ticket?.id)
56
+ member.ticket = s.ticket.id;
57
+ if (s.machine)
58
+ member.host = s.machine;
59
+ r.members.push(member);
44
60
  if (s.todos) {
45
61
  r.plan.done += s.todos.done;
46
62
  r.plan.total += s.todos.total;
@@ -67,6 +83,59 @@ export function planPct(plan) {
67
83
  return undefined;
68
84
  return Math.round((plan.done / plan.total) * 100);
69
85
  }
86
+ /**
87
+ * Display order for the members line: the states a human scans for first
88
+ * (running, then idle, then need-input, then queued), everything else after,
89
+ * status name then agent name ascending within a state.
90
+ */
91
+ const MEMBER_STATUS_RANK = { running: 0, idle: 1, input_required: 2, queued: 3 };
92
+ /** Sort members for the card: running first, then idle, then the rest; agent name asc within a state. */
93
+ export function sortProjectMembers(members) {
94
+ return [...members].sort((a, b) => {
95
+ const ra = MEMBER_STATUS_RANK[a.status] ?? 4;
96
+ const rb = MEMBER_STATUS_RANK[b.status] ?? 4;
97
+ if (ra !== rb)
98
+ return ra - rb;
99
+ if (ra === 4 && a.status !== b.status)
100
+ return a.status.localeCompare(b.status);
101
+ return a.agent.localeCompare(b.agent);
102
+ });
103
+ }
104
+ /** Cap for the members line before it collapses to `+N more`. */
105
+ export const MEMBERS_LINE_LIMIT = 6;
106
+ /**
107
+ * The `agents` line under `live`: one cell per DISTINCT member state —
108
+ * `claude · running · RUSH-2107 @zion` — with identical cells collapsed to a
109
+ * `×N` count (35 same-harness sessions in one state are one fact, not six
110
+ * truncated duplicates), capped at {@link MEMBERS_LINE_LIMIT} cells with a
111
+ * `+N more` tail counting members, not cells. Pure (chalk styling only); the
112
+ * caller adds the label.
113
+ */
114
+ export function formatProjectMembers(members, limit = MEMBERS_LINE_LIMIT) {
115
+ if (members.length === 0)
116
+ return '';
117
+ // Collapse identical cells — 35 same-harness sessions in the same state are
118
+ // one fact (`claude · running ×16`), not six truncated duplicates.
119
+ const counts = new Map();
120
+ for (const m of sortProjectMembers(members)) {
121
+ const parts = [m.agent, m.status];
122
+ if (m.ticket)
123
+ parts.push(m.ticket);
124
+ const cell = parts.join(' · ') + (m.host ? ` @${m.host}` : '');
125
+ const key = cell.toLowerCase();
126
+ const entry = counts.get(key);
127
+ if (entry)
128
+ entry.n++;
129
+ else
130
+ counts.set(key, { cell, n: 1 });
131
+ }
132
+ const entries = [...counts.values()];
133
+ const shown = entries.slice(0, Math.max(1, limit));
134
+ const shownMembers = shown.reduce((acc, e) => acc + e.n, 0);
135
+ const more = members.length - shownMembers;
136
+ const cells = shown.map(({ cell, n }) => (n > 1 ? `${cell} ×${n}` : cell));
137
+ return cells.join(chalk.dim(' · ')) + (more > 0 ? chalk.dim(` · +${more} more`) : '');
138
+ }
70
139
  /**
71
140
  * Harvest the signals that don't live on the active-session list: recently
72
141
  * merged PRs (from GitHub via `gh`) and artifacts agents produced (from the
@@ -96,6 +165,18 @@ export async function enrichProjectSignals(def, windowDays, nowMs, opts = {}) {
96
165
  catch {
97
166
  /* gh missing / unauthenticated / repo not found — skip this signal */
98
167
  }
168
+ // Latest release of the PRIMARY repo only (repos[] is deliberately not
169
+ // scanned — one release line per card). Same best-effort degradation.
170
+ try {
171
+ const { stdout } = await execFileAsync('gh', ['release', 'list', '-R', def.repo, '-L', '1', '--json', 'tagName,publishedAt'], { timeout: 8000, encoding: 'utf8' });
172
+ const rows = JSON.parse(stdout);
173
+ const first = rows[0];
174
+ if (first?.tagName)
175
+ out.latestRelease = { tag: first.tagName, publishedAt: first.publishedAt ?? '' };
176
+ }
177
+ catch {
178
+ /* gh missing / unauthenticated / repo has no releases — skip this signal */
179
+ }
99
180
  }
100
181
  return out;
101
182
  }
@@ -24,6 +24,12 @@ export interface ProjectRepo {
24
24
  slug: string;
25
25
  /** Optional path within the repo an agent working this project cares about. */
26
26
  subpath?: string;
27
+ /**
28
+ * Optional home-relative local checkout of this repo. The def's `root` only
29
+ * knows the primary repo on disk; `path` opts an additional repo into
30
+ * workspace probing (`projects status --fleet`).
31
+ */
32
+ path?: string;
27
33
  }
28
34
  /**
29
35
  * A described context anchor: a subdirectory plus what it is. Agents starting on
@@ -86,9 +86,15 @@ export function validateProjectDef(raw, sourceName) {
86
86
  def.repos = o.repos.flatMap((r) => {
87
87
  if (r && typeof r === 'object' && typeof r.slug === 'string') {
88
88
  const rr = r;
89
+ // A malformed `path` sinks the whole entry, like any other malformed
90
+ // list row — a half-valid repo must not probe a surprising location.
91
+ if (rr.path !== undefined && typeof rr.path !== 'string')
92
+ return [];
89
93
  const repo = { slug: rr.slug };
90
94
  if (typeof rr.subpath === 'string')
91
95
  repo.subpath = rr.subpath;
96
+ if (typeof rr.path === 'string')
97
+ repo.path = rr.path;
92
98
  return [repo];
93
99
  }
94
100
  return [];
@@ -192,6 +198,12 @@ export function writeProjectDef(def) {
192
198
  defaultPath: validated.defaultPath
193
199
  ? toHomeRelative(expandLocalHome(validated.defaultPath))
194
200
  : undefined,
201
+ repos: validated.repos?.map((r) => {
202
+ const repo = { ...r };
203
+ if (r.path)
204
+ repo.path = toHomeRelative(expandLocalHome(r.path));
205
+ return repo;
206
+ }),
195
207
  };
196
208
  // Drop undefined keys so the YAML stays clean.
197
209
  const clean = Object.fromEntries(Object.entries(normalized).filter(([, v]) => v !== undefined));
@@ -2,7 +2,7 @@ export interface RemoteAgentsJsonOptions<T> {
2
2
  args: string[];
3
3
  noFanoutEnv: string;
4
4
  hosts?: string[];
5
- parse: (stdout: string, machine: string) => T[];
5
+ parse: (stdout: string, machine: string) => T[] | RemoteAgentsJsonParseResult<T>;
6
6
  /**
7
7
  * Suppress the per-device "unreachable — skipped" stderr line. The skipped
8
8
  * names still come back in {@link RemoteAgentsJsonResult.skipped}, so a caller
@@ -11,12 +11,25 @@ export interface RemoteAgentsJsonOptions<T> {
11
11
  */
12
12
  quiet?: boolean;
13
13
  }
14
+ export interface RemoteAgentsJsonParseResult<T> {
15
+ items: T[];
16
+ valid: boolean;
17
+ }
14
18
  export interface RemoteAgentsJsonResult<T> {
15
19
  items: T[];
16
20
  deviceCount: number;
17
21
  /** Devices that were dialed but answered with an error / no CLI / a timeout. */
18
22
  skipped: string[];
23
+ /** Devices that exited successfully but returned invalid JSON for this command. */
24
+ parseFailed: string[];
25
+ /** Whether automatic target discovery failed before any peer could be dialed. */
26
+ discoveryFailed: boolean;
19
27
  }
28
+ export declare function normalizeRemoteAgentsJsonParse<T>(parsed: T[] | RemoteAgentsJsonParseResult<T>): RemoteAgentsJsonParseResult<T>;
29
+ export declare function parseRemoteAgentsJsonPayload<T>(stdout: string, machine: string, parse: RemoteAgentsJsonOptions<T>['parse']): {
30
+ items: T[];
31
+ parseFailed: boolean;
32
+ };
20
33
  /** Build the command one peer runs, with a guard that prevents recursive fan-out. */
21
34
  export declare function remoteAgentsJsonCommand(args: string[], noFanoutEnv: string, os?: string): string;
22
35
  /** Query explicit hosts, or every registered online peer when hosts is omitted. */
@@ -15,6 +15,15 @@ import { loadDevices, isControlDevice, isDialableDevice } from './devices/regist
15
15
  import { remoteShellFor, buildWindowsAgentsCommand } from './hosts/remote-cmd.js';
16
16
  import { machineId, normalizeHost } from './machine-id.js';
17
17
  const REMOTE_TIMEOUT_MS = 12_000;
18
+ export function normalizeRemoteAgentsJsonParse(parsed) {
19
+ return Array.isArray(parsed) ? { items: parsed, valid: true } : parsed;
20
+ }
21
+ export function parseRemoteAgentsJsonPayload(stdout, machine, parse) {
22
+ const parsed = normalizeRemoteAgentsJsonParse(parse(stdout, machine));
23
+ return parsed.valid
24
+ ? { items: parsed.items, parseFailed: false }
25
+ : { items: [], parseFailed: true };
26
+ }
18
27
  /** Build the command one peer runs, with a guard that prevents recursive fan-out. */
19
28
  export function remoteAgentsJsonCommand(args, noFanoutEnv, os) {
20
29
  if (remoteShellFor(os) === 'powershell') {
@@ -59,7 +68,7 @@ export async function gatherRemoteAgentsJson(options) {
59
68
  devices = await loadDevices();
60
69
  }
61
70
  catch {
62
- return { items: [], deviceCount: 0, skipped: [] };
71
+ return { items: [], deviceCount: 0, skipped: [], parseFailed: [], discoveryFailed: true };
63
72
  }
64
73
  for (const device of Object.values(devices)) {
65
74
  // Live SSH-probe verdict first, cached tailscale snapshot only as a
@@ -90,6 +99,7 @@ export async function gatherRemoteAgentsJson(options) {
90
99
  }
91
100
  }
92
101
  const skipped = [];
102
+ const parseFailed = [];
93
103
  const results = await Promise.all(targets.map(async (target) => {
94
104
  const command = remoteAgentsJsonCommand(options.args, options.noFanoutEnv, target.os);
95
105
  const result = await sshCapture(target.target, command);
@@ -100,7 +110,15 @@ export async function gatherRemoteAgentsJson(options) {
100
110
  }
101
111
  return [];
102
112
  }
103
- return options.parse(result.stdout, target.machine);
113
+ const parsed = parseRemoteAgentsJsonPayload(result.stdout, target.machine, options.parse);
114
+ if (parsed.parseFailed) {
115
+ parseFailed.push(target.name);
116
+ if (!options.quiet) {
117
+ process.stderr.write(chalk.gray(` ${target.name}: unreachable or no agents CLI — skipped\n`));
118
+ }
119
+ return [];
120
+ }
121
+ return parsed.items;
104
122
  }));
105
- return { items: results.flat(), deviceCount: targets.length, skipped };
123
+ return { items: results.flat(), deviceCount: targets.length, skipped, parseFailed, discoveryFailed: false };
106
124
  }
@@ -26,6 +26,17 @@ import { type DesktopNotification } from './menubar/notify-desktop.js';
26
26
  type RoutineKind = 'agent' | 'workflow' | 'command';
27
27
  /** Which flavor of routine a config/meta describes — drives the notify threshold. */
28
28
  export declare function routineKind(r: Pick<JobConfig, 'agent' | 'workflow' | 'command'>): RoutineKind;
29
+ /**
30
+ * The harness a routine runs on, for the notification's right-hand avatar, or
31
+ * undefined when none owns it. A command routine is deterministic housekeeping
32
+ * with no agent, so it gets no avatar. An agent routine names its own harness.
33
+ * A workflow routine has no `agent` field (the schema omits it — routines.ts
34
+ * `JobConfig.agent` and the validation that rejects setting both), and it runs
35
+ * via `agents run <workflow>`, which delegates to claude under the hood — so its
36
+ * avatar is the Claude mark, matching `effectiveAgent` on the finish path
37
+ * (runner.ts). Start and finish banners therefore show the same avatar.
38
+ */
39
+ export declare function routineAgent(r: Pick<JobConfig, 'agent' | 'workflow' | 'command'>): string | undefined;
29
40
  /** "1m 20s" / "45s" / "2h 3m" from a millisecond duration, or null when unknown. */
30
41
  export declare function formatDuration(ms: number | undefined): string | null;
31
42
  /**
@@ -33,6 +33,24 @@ export function routineKind(r) {
33
33
  return 'workflow';
34
34
  return 'agent';
35
35
  }
36
+ /**
37
+ * The harness a routine runs on, for the notification's right-hand avatar, or
38
+ * undefined when none owns it. A command routine is deterministic housekeeping
39
+ * with no agent, so it gets no avatar. An agent routine names its own harness.
40
+ * A workflow routine has no `agent` field (the schema omits it — routines.ts
41
+ * `JobConfig.agent` and the validation that rejects setting both), and it runs
42
+ * via `agents run <workflow>`, which delegates to claude under the hood — so its
43
+ * avatar is the Claude mark, matching `effectiveAgent` on the finish path
44
+ * (runner.ts). Start and finish banners therefore show the same avatar.
45
+ */
46
+ export function routineAgent(r) {
47
+ const kind = routineKind(r);
48
+ if (kind === 'command')
49
+ return undefined;
50
+ if (kind === 'workflow')
51
+ return 'claude';
52
+ return r.agent?.trim() || undefined;
53
+ }
36
54
  /** Human label for the routine body ("agent claude", "workflow deploy", "command"). */
37
55
  function routineLabel(r) {
38
56
  if (r.command)
@@ -90,6 +108,7 @@ export function routineStartNotification(config) {
90
108
  subtitle: config.name,
91
109
  body: `Running ${routineLabel(config)}`,
92
110
  action: 'routines:list',
111
+ agent: routineAgent(config),
93
112
  };
94
113
  }
95
114
  /**
@@ -108,6 +127,7 @@ export function routineStartFailedNotification(config, error) {
108
127
  subtitle: config.name,
109
128
  body: `Failed to start: ${error}`,
110
129
  action: 'routines:list',
130
+ agent: routineAgent(config),
111
131
  };
112
132
  }
113
133
  /**
@@ -131,6 +151,7 @@ export function routineFinishNotification(meta, opts = {}) {
131
151
  subtitle: meta.jobName,
132
152
  body: snippet ?? (dur ? `Completed in ${dur}` : 'Completed'),
133
153
  action,
154
+ agent: routineAgent(meta),
134
155
  };
135
156
  }
136
157
  // failed | timeout
@@ -144,6 +165,7 @@ export function routineFinishNotification(meta, opts = {}) {
144
165
  subtitle: meta.jobName,
145
166
  body: reason,
146
167
  action,
168
+ agent: routineAgent(meta),
147
169
  };
148
170
  }
149
171
  /** Read a finished run's report text + the best artifact to open on click. */
@@ -293,6 +293,47 @@ export declare function finalizeRunMeta(meta: RunMeta, status: RunMeta['status']
293
293
  * catchup/overdue, manual run) gates on this.
294
294
  */
295
295
  export declare function jobRunsOnThisDevice(config: Pick<JobConfig, 'devices'>): boolean;
296
+ /**
297
+ * The ONE device that owns a routine — the single daemon allowed to fire it.
298
+ *
299
+ * `devices` is an allowlist, and every listed device used to fire
300
+ * independently, so a routine pinned to two boxes ran **twice** per schedule:
301
+ * two full agent sessions doing identical work, burning double the quota. On
302
+ * this fleet seven routines were in that state, e.g. `security-sweep` running
303
+ * at 15:30:02 on one box and 15:30:03 on the other, both completing.
304
+ *
305
+ * Ownership is a pure function of the config — the first entry in normalized
306
+ * sort order — so every daemon independently reaches the same answer with no
307
+ * lease, no cross-device coordination, and no split brain when the fleet
308
+ * partitions. A multi-entry pin is a misconfiguration
309
+ * ({@link hasAmbiguousDevicePin}); this keeps such a routine running exactly
310
+ * once instead of silently dropping it, while `validateJob` refuses to create
311
+ * a new one and `agents doctor` surfaces the existing ones.
312
+ *
313
+ * Returns null when the routine is unrestricted (empty or omitted `devices`).
314
+ */
315
+ export declare function routineOwnerDevice(config: Pick<JobConfig, 'devices'>): string | null;
316
+ /**
317
+ * Does this routine name more than one distinct device? Such a pin used to mean
318
+ * "fire on each of them"; it now means "fire only on the first", which is
319
+ * almost certainly not what the author intended either way — so it is reported
320
+ * as a misconfiguration rather than silently reinterpreted.
321
+ */
322
+ export declare function hasAmbiguousDevicePin(config: Pick<JobConfig, 'devices'>): boolean;
323
+ /** One routine whose `devices` names more than one machine, with its resolved owner. */
324
+ export interface AmbiguousDevicePin {
325
+ name: string;
326
+ devices: string[];
327
+ /** The device that now fires it — the rest are inert. */
328
+ owner: string;
329
+ }
330
+ /**
331
+ * Every routine carrying a multi-device pin. Surfaced by `agents doctor` and
332
+ * `agents routines list` so an existing misconfiguration is visible rather than
333
+ * silently reinterpreted: before ownership became singular each of these fired
334
+ * once per listed device, doubling the work and the agent spend.
335
+ */
336
+ export declare function findAmbiguousDevicePins(cwd?: string): AmbiguousDevicePin[];
296
337
  /**
297
338
  * Resolve the effective host strategy for a job.
298
339
  * Bare `host:` without an explicit strategy implies `host` (back-compat with
@@ -487,6 +528,18 @@ export declare function discoverJobsFromRepo(repoPath: string): Array<{
487
528
  export declare function jobExists(name: string): boolean;
488
529
  /** Get the filesystem path of a job's YAML config file, or null if not found. */
489
530
  export declare function getJobPath(name: string): string | null;
531
+ /**
532
+ * Resolve a routine's YAML across EVERY layer `listJobs`/`readJob` read — user
533
+ * then system — not just the user dir.
534
+ *
535
+ * `getJobPath` is user-layer only because its callers write there. Read paths
536
+ * that ask "when did this routine come to exist" need the system layer too:
537
+ * a built-in shipped in the system repo has no user-layer file and no
538
+ * `createdAt`, so a user-layer-only lookup returns null, the overdue floor is
539
+ * skipped, and the routine reads as instantly overdue on first daemon start —
540
+ * exactly the case the floor exists to prevent.
541
+ */
542
+ export declare function resolveJobFilePath(name: string): string | null;
490
543
  /**
491
544
  * Parse an "at" time string into a one-shot cron expression.
492
545
  * Supports formats like: