@phnx-labs/agents-cli 1.20.88 → 1.20.89

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 (55) hide show
  1. package/CHANGELOG.md +263 -0
  2. package/README.md +9 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/factory.js +26 -2
  6. package/dist/commands/funnel.js +16 -1
  7. package/dist/commands/menubar.js +117 -34
  8. package/dist/commands/routines.js +23 -1
  9. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  10. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  11. package/dist/commands/secrets.js +2 -0
  12. package/dist/commands/sessions.d.ts +7 -1
  13. package/dist/commands/sessions.js +39 -12
  14. package/dist/commands/webhook.js +7 -2
  15. package/dist/lib/commands.js +9 -1
  16. package/dist/lib/daemon.d.ts +29 -0
  17. package/dist/lib/daemon.js +58 -4
  18. package/dist/lib/events.d.ts +1 -1
  19. package/dist/lib/factory/snapshot.d.ts +78 -0
  20. package/dist/lib/factory/snapshot.js +209 -0
  21. package/dist/lib/fs-atomic.d.ts +14 -1
  22. package/dist/lib/fs-atomic.js +35 -3
  23. package/dist/lib/funnel.d.ts +1 -0
  24. package/dist/lib/funnel.js +8 -0
  25. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  26. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  27. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  28. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  29. package/dist/lib/menubar/install-menubar.js +183 -28
  30. package/dist/lib/platform/process.d.ts +2 -0
  31. package/dist/lib/platform/process.js +5 -3
  32. package/dist/lib/resources.d.ts +8 -0
  33. package/dist/lib/resources.js +34 -1
  34. package/dist/lib/routines-placement.d.ts +2 -1
  35. package/dist/lib/routines-placement.js +8 -4
  36. package/dist/lib/routines.d.ts +57 -1
  37. package/dist/lib/routines.js +74 -1
  38. package/dist/lib/runner.d.ts +2 -0
  39. package/dist/lib/runner.js +21 -8
  40. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  41. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  42. package/dist/lib/secrets/bundles.js +9 -34
  43. package/dist/lib/secrets/filestore.d.ts +152 -34
  44. package/dist/lib/secrets/filestore.js +676 -123
  45. package/dist/lib/session/remote-active.d.ts +4 -1
  46. package/dist/lib/session/remote-active.js +8 -2
  47. package/dist/lib/session/viewing-in.d.ts +31 -0
  48. package/dist/lib/session/viewing-in.js +47 -0
  49. package/dist/lib/state.d.ts +17 -0
  50. package/dist/lib/state.js +30 -2
  51. package/dist/lib/triggers/handlers.d.ts +95 -0
  52. package/dist/lib/triggers/handlers.js +384 -0
  53. package/dist/lib/triggers/webhook.d.ts +10 -2
  54. package/dist/lib/triggers/webhook.js +65 -11
  55. package/package.json +1 -1
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Read-only Software Factory state aggregation.
3
+ *
4
+ * ~/.agents/factory.yml example:
5
+ *
6
+ * ceiling: 4
7
+ * max_dispatch_per_tick: 2
8
+ * per_project:
9
+ * Agents CLI: { weight: 2, cap: 2 }
10
+ * idle_boxes: [yosemite-m1, yosemite-m2]
11
+ * digest: { times: ["09:00", "17:00"], tz: America/Los_Angeles }
12
+ */
13
+ import * as fs from 'fs';
14
+ import * as os from 'os';
15
+ import * as path from 'path';
16
+ import { execFile as execFileCallback } from 'child_process';
17
+ import { promisify } from 'util';
18
+ import * as yaml from 'yaml';
19
+ import { getActiveSessions } from '../session/active.js';
20
+ import { serializeActiveSessionsForJson } from '../../commands/sessions.js';
21
+ import { readAuthHealthCache } from '../auth-health.js';
22
+ import { readStatsCache } from '../devices/stats-cache.js';
23
+ const execFile = promisify(execFileCallback);
24
+ export const FACTORY_PROJECTS = [
25
+ { name: 'Prix', repo: 'phnx-labs/prix' },
26
+ { name: 'Rush App', repo: 'phnx-labs/rush' },
27
+ { name: 'Rush CLI', repo: 'phnx-labs/rush-cli' },
28
+ { name: 'Agents CLI', repo: 'phnx-labs/agents-cli' },
29
+ { name: 'Linear CLI', repo: 'phnx-labs/linear-cli' },
30
+ ];
31
+ const defaults = () => ({
32
+ source: 'default',
33
+ ceiling: 4,
34
+ max_dispatch_per_tick: 2,
35
+ per_project: {},
36
+ idle_boxes: [],
37
+ digest: { times: [], tz: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' },
38
+ });
39
+ export function readFactoryConfig(home) {
40
+ const file = path.join(home, '.agents', 'factory.yml');
41
+ if (!fs.existsSync(file))
42
+ return defaults();
43
+ const raw = yaml.parse(fs.readFileSync(file, 'utf8'));
44
+ if (!raw || typeof raw !== 'object')
45
+ throw new Error(`${file} must contain a YAML mapping`);
46
+ const base = defaults();
47
+ return {
48
+ source: 'file',
49
+ ceiling: integer(raw.ceiling ?? base.ceiling, 'ceiling'),
50
+ max_dispatch_per_tick: integer(raw.max_dispatch_per_tick ?? base.max_dispatch_per_tick, 'max_dispatch_per_tick'),
51
+ per_project: parseProjectConfig(raw.per_project),
52
+ idle_boxes: stringArray(raw.idle_boxes, 'idle_boxes'),
53
+ digest: raw.digest ? {
54
+ times: stringArray(raw.digest.times, 'digest.times'),
55
+ tz: typeof raw.digest.tz === 'string' ? raw.digest.tz : base.digest.tz,
56
+ } : base.digest,
57
+ };
58
+ }
59
+ function integer(value, name) {
60
+ if (!Number.isInteger(value) || value < 0)
61
+ throw new Error(`factory.yml ${name} must be a non-negative integer`);
62
+ return value;
63
+ }
64
+ function stringArray(value, name) {
65
+ if (value === undefined)
66
+ return [];
67
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string'))
68
+ throw new Error(`factory.yml ${name} must be a string array`);
69
+ return value;
70
+ }
71
+ function parseProjectConfig(value) {
72
+ if (value === undefined)
73
+ return {};
74
+ if (!value || typeof value !== 'object' || Array.isArray(value))
75
+ throw new Error('factory.yml per_project must be a mapping');
76
+ return Object.fromEntries(Object.entries(value).map(([name, item]) => {
77
+ const record = item;
78
+ return [name, { weight: integer(record?.weight, `per_project.${name}.weight`), cap: integer(record?.cap, `per_project.${name}.cap`) }];
79
+ }));
80
+ }
81
+ function json(text) {
82
+ return JSON.parse(text);
83
+ }
84
+ function issueCount(payload) {
85
+ if (Array.isArray(payload))
86
+ return payload.length;
87
+ if (payload && typeof payload === 'object') {
88
+ const record = payload;
89
+ if (typeof record.count === 'number')
90
+ return record.count;
91
+ if (Array.isArray(record.issues))
92
+ return record.issues.length;
93
+ }
94
+ return 0;
95
+ }
96
+ export function queueCounts(todoPayload, openPayload) {
97
+ const issues = openPayload && typeof openPayload === 'object' && Array.isArray(openPayload.issues)
98
+ ? openPayload.issues : [];
99
+ const blocked = issues.filter((issue) => {
100
+ const state = issue.state && typeof issue.state === 'object' ? issue.state : {};
101
+ const labels = issue.labels && typeof issue.labels === 'object' && Array.isArray(issue.labels.nodes)
102
+ ? issue.labels.nodes : [];
103
+ return String(state.name ?? '').toLowerCase().includes('block') || labels.some((label) => String(label.name ?? '').toLowerCase() === 'blocked');
104
+ }).length;
105
+ const inProgress = issues.filter((issue) => {
106
+ const state = issue.state && typeof issue.state === 'object' ? issue.state : {};
107
+ return String(state.type ?? '').toLowerCase() === 'started' && !String(state.name ?? '').toLowerCase().includes('block');
108
+ }).length;
109
+ return { todo: issueCount(todoPayload), inProgress, blocked };
110
+ }
111
+ export function parsePullRequests(repo, payload) {
112
+ if (!Array.isArray(payload))
113
+ return [];
114
+ return payload.map((raw) => {
115
+ const pr = raw;
116
+ const checks = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : [];
117
+ const states = checks.map((check) => String(check.conclusion ?? check.state ?? check.status ?? '').toUpperCase());
118
+ const ci = states.some((s) => ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT'].includes(s)) ? 'failing'
119
+ : states.some((s) => ['', 'PENDING', 'QUEUED', 'IN_PROGRESS'].includes(s)) ? 'pending'
120
+ : states.length > 0 ? 'passing' : 'none';
121
+ return {
122
+ repo,
123
+ number: Number(pr.number),
124
+ ci,
125
+ review: String(pr.reviewDecision ?? 'none').toLowerCase(),
126
+ mergeable: String(pr.mergeable ?? 'unknown').toLowerCase(),
127
+ };
128
+ });
129
+ }
130
+ export function parseDevices(payload, cached = {}) {
131
+ const rows = Array.isArray(payload) ? payload : [];
132
+ return rows.map((raw) => {
133
+ const row = raw;
134
+ const name = String(row.name ?? row.host ?? '');
135
+ const stats = row.stats && typeof row.stats === 'object' ? row.stats : cached[name] ?? row;
136
+ const candidate = stats.loadPercent ?? stats.load ?? stats.loadPct;
137
+ const load = typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : null;
138
+ return { name, load, idle: row.idle === true || (load !== null && load < 20) };
139
+ }).filter((row) => row.name.length > 0);
140
+ }
141
+ export function readRecentRuns(home, limit = 3) {
142
+ const root = path.join(home, '.agents', '.history', 'runs');
143
+ if (!fs.existsSync(root))
144
+ return [];
145
+ const found = [];
146
+ for (const routine of fs.readdirSync(root)) {
147
+ const routineDir = path.join(root, routine);
148
+ if (!fs.statSync(routineDir).isDirectory())
149
+ continue;
150
+ const routineRuns = [];
151
+ for (const run of fs.readdirSync(routineDir)) {
152
+ const file = path.join(routineDir, run, 'meta.json');
153
+ if (!fs.existsSync(file))
154
+ continue;
155
+ try {
156
+ const meta = JSON.parse(fs.readFileSync(file, 'utf8'));
157
+ const started = Date.parse(String(meta.startedAt ?? meta.createdAt ?? ''));
158
+ const ended = Date.parse(String(meta.finishedAt ?? meta.completedAt ?? meta.updatedAt ?? ''));
159
+ const duration = typeof meta.durationMs === 'number' ? meta.durationMs : Number.isFinite(started) && Number.isFinite(ended) ? ended - started : null;
160
+ routineRuns.push({ routine, status: String(meta.status ?? 'unknown'), durationMs: duration, mtime: fs.statSync(file).mtimeMs });
161
+ }
162
+ catch { /* A concurrently-written or malformed run is not a completed outcome. */ }
163
+ }
164
+ found.push(...routineRuns.sort((a, b) => b.mtime - a.mtime).slice(0, limit));
165
+ }
166
+ return found.sort((a, b) => b.mtime - a.mtime).map(({ mtime: _, ...run }) => run);
167
+ }
168
+ function latestClaudeVerdict(entries) {
169
+ return Object.entries(entries)
170
+ .filter(([key]) => key.split(':').includes('claude'))
171
+ .map(([, value]) => value)
172
+ .sort((a, b) => b.checkedAt - a.checkedAt)[0]?.verdict ?? null;
173
+ }
174
+ export async function buildFactorySnapshot(overrides = {}) {
175
+ const deps = {
176
+ home: os.homedir(),
177
+ now: () => new Date(),
178
+ activeSessions: () => getActiveSessions(),
179
+ run: async (file, args) => (await execFile(file, args, { maxBuffer: 10 * 1024 * 1024 })).stdout,
180
+ readAuth: readAuthHealthCache,
181
+ readDeviceStats: readStatsCache,
182
+ ...overrides,
183
+ };
184
+ const linear = path.join(deps.home, '.agents', 'skills', 'linear', 'scripts', 'linear');
185
+ const safeRun = async (file, args) => {
186
+ try {
187
+ return json(await deps.run(file, args));
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ };
193
+ const sessionsPromise = deps.activeSessions().then(serializeActiveSessionsForJson);
194
+ const queuePromise = Promise.all(FACTORY_PROJECTS.map(async ({ name }) => {
195
+ const query = (status) => safeRun(linear, ['tasks', '--project', name, '--label', 'pilot', '--status', status, '--cycle', 'all', '--all', '--json']);
196
+ const [todo, open] = await Promise.all([query('todo'), query('open')]);
197
+ return [name, queueCounts(todo, open)];
198
+ })).then(Object.fromEntries);
199
+ const prsPromise = Promise.all(FACTORY_PROJECTS.map(async ({ repo }) => parsePullRequests(repo, await safeRun('gh', ['pr', 'list', '--repo', repo, '--state', 'open', '--json', 'number,title,statusCheckRollup,reviewDecision,mergeable'])))).then((rows) => rows.flat());
200
+ // `devices list --json` is the registry's read-only JSON surface. Load comes
201
+ // from the daemon-warmed cache so snapshot never probes or writes reachability.
202
+ const devicesPromise = safeRun('agents', ['devices', 'list', '--json']).then((payload) => parseDevices(payload, deps.readDeviceStats()));
203
+ const [sessions, queues, prs, devices] = await Promise.all([sessionsPromise, queuePromise, prsPromise, devicesPromise]);
204
+ return {
205
+ generatedAt: deps.now().toISOString(), sessions, queues, prs, devices,
206
+ recentRuns: readRecentRuns(deps.home), auth: { claude: latestClaudeVerdict(deps.readAuth()) },
207
+ config: readFactoryConfig(deps.home),
208
+ };
209
+ }
@@ -16,9 +16,22 @@ export declare function atomicWriteFileSync(filePath: string, content: string, o
16
16
  * releases the lock. Retries with capped linear back-off until either the lock
17
17
  * is acquired or LOCK_ACQUIRE_TIMEOUT_MS elapses. Breaks stale locks older than
18
18
  * LOCK_STALE_MS, so a crashed holder never blocks past the stale window.
19
+ *
20
+ * `fn` is handed a `heartbeat()` it can call during a long, fully SYNCHRONOUS
21
+ * critical section. proper-lockfile keeps a held lock "alive" by refreshing its
22
+ * lockfile mtime on a `setTimeout` every `stale/2` — but that timer only fires
23
+ * when the event loop gets a turn. A synchronous hold that outruns `stale`
24
+ * (e.g. the scrypt-bound rotation loop in filestore.ts, ~16s on a real store)
25
+ * never yields, so the timer cannot run: the lock ages past `stale` mid-hold and a
26
+ * peer contending for it treats the live holder as crashed, breaks the lock, and
27
+ * interleaves — corrupting the invariant the lock exists to protect, with no crash
28
+ * involved. `heartbeat()` drives the same refresh synchronously (bumps the lockfile
29
+ * mtime), so a long sync holder stays fresh while the short `stale` window still
30
+ * detects a genuinely crashed holder within LOCK_STALE_MS. Callers whose critical
31
+ * section is short (a single read-modify-write) can ignore it.
19
32
  */
20
33
  export interface FileLockOptions {
21
34
  staleMs?: number;
22
35
  acquireTimeoutMs?: number;
23
36
  }
24
- export declare function withFileLock<T>(filePath: string, fn: () => T, opts?: FileLockOptions): T;
37
+ export declare function withFileLock<T>(filePath: string, fn: (heartbeat: () => void) => T, opts?: FileLockOptions): T;
@@ -58,12 +58,20 @@ export function atomicWriteFileSync(filePath, content, options = 'utf-8') {
58
58
  export function withFileLock(filePath, fn, opts = {}) {
59
59
  let release = null;
60
60
  let lastError;
61
+ // Set if a peer breaks this lock while we hold it. proper-lockfile reports that
62
+ // from its own refresh TIMER, so the default handler rethrows asynchronously —
63
+ // an uncatchable crash of the whole CLI process, from a callback no caller is
64
+ // on the stack for. Capture it instead and surface it synchronously below.
65
+ let compromised = null;
61
66
  const staleMs = opts.staleMs ?? LOCK_STALE_MS;
62
67
  const acquireTimeoutMs = opts.acquireTimeoutMs ?? LOCK_ACQUIRE_TIMEOUT_MS;
63
68
  const deadline = Date.now() + acquireTimeoutMs;
64
69
  for (let attempt = 0;; attempt++) {
65
70
  try {
66
- release = lockfile.lockSync(filePath, { stale: staleMs });
71
+ release = lockfile.lockSync(filePath, {
72
+ stale: staleMs,
73
+ onCompromised: (err) => { compromised = err; },
74
+ });
67
75
  break;
68
76
  }
69
77
  catch (err) {
@@ -78,10 +86,34 @@ export function withFileLock(filePath, fn, opts = {}) {
78
86
  const message = lastError instanceof Error ? lastError.message : String(lastError);
79
87
  throw new Error(`Could not acquire lock for ${filePath} after ${acquireTimeoutMs}ms: ${message}`);
80
88
  }
89
+ // proper-lockfile's lock dir is `<filePath>.lock`; touching its mtime is exactly
90
+ // what proper-lockfile's own async updater does, so the staleness check keys off
91
+ // a fresh mtime. Best-effort: a failed touch just leaves the async updater's
92
+ // behaviour unchanged (no worse than before this heartbeat existed).
93
+ const lockDir = `${filePath}.lock`;
94
+ const heartbeat = () => {
95
+ try {
96
+ const now = new Date();
97
+ fs.utimesSync(lockDir, now, now);
98
+ }
99
+ catch { /* best effort */ }
100
+ };
81
101
  try {
82
- return fn();
102
+ const result = fn(heartbeat);
103
+ // A compromised lock means a peer may have written under us — the caller must
104
+ // not treat the result as if it held exclusivity throughout.
105
+ if (compromised) {
106
+ throw new Error(`Lock for ${filePath} was broken by another process while held: ` +
107
+ `${compromised.message}`);
108
+ }
109
+ return result;
83
110
  }
84
111
  finally {
85
- release();
112
+ // Releasing a lock a peer already stole throws ENOTACQUIRED; that is the
113
+ // stolen case, already reported above, so don't mask it with a teardown error.
114
+ try {
115
+ release();
116
+ }
117
+ catch { /* already gone */ }
86
118
  }
87
119
  }
@@ -2,4 +2,5 @@ export declare const FUNNEL_PORTS: readonly [443, 8443, 10000];
2
2
  export type FunnelPort = typeof FUNNEL_PORTS[number];
3
3
  export declare function parseFunnelPort(value: string | number): FunnelPort;
4
4
  export declare function buildFunnelStatusCommand(): string;
5
+ export declare function buildFunnelDownCommand(publicPort: FunnelPort): string;
5
6
  export declare function buildFunnelUpCommand(publicPort: FunnelPort, localPort: number): string;
@@ -9,6 +9,14 @@ export function parseFunnelPort(value) {
9
9
  export function buildFunnelStatusCommand() {
10
10
  return 'tailscale funnel status';
11
11
  }
12
+ export function buildFunnelDownCommand(publicPort) {
13
+ return [
14
+ 'tailscale',
15
+ 'funnel',
16
+ `--https=${publicPort}`,
17
+ 'off',
18
+ ].map(shellQuote).join(' ');
19
+ }
12
20
  export function buildFunnelUpCommand(publicPort, localPort) {
13
21
  if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
14
22
  throw new Error('Local port must be between 1 and 65535');
@@ -6,7 +6,7 @@
6
6
  <dict>
7
7
  <key>Resources/AppIcon.icns</key>
8
8
  <data>
9
- DFq5H08EkhgWIC3UvGMR9B58BZw=
9
+ jOjZVimcFRHoP2VPzgn8uM8mjkA=
10
10
  </data>
11
11
  </dict>
12
12
  <key>files2</key>
@@ -15,7 +15,7 @@
15
15
  <dict>
16
16
  <key>hash2</key>
17
17
  <data>
18
- mBSjM6jlvN7J1jQowsvqTl7CHM0pur0e6qsHMuzk5k0=
18
+ GFvSLeNYJ3ASxW3OzcuiB4aID0gZUBkpozmWZkbTFNw=
19
19
  </data>
20
20
  </dict>
21
21
  </dict>
@@ -124,6 +124,48 @@ export declare function disableMenubarService(): void;
124
124
  * Best-effort — never throws into startup.
125
125
  */
126
126
  export declare function installMenubarLaunchAgentOnUpgrade(): void;
127
+ /** One step of `agents menubar setup`, and how it came out. */
128
+ export interface SetupStep {
129
+ /** What was configured. */
130
+ name: string;
131
+ /** `ok` — already correct or now correct; `changed` — this run fixed it;
132
+ * `failed` — could not be configured (setup reports and exits nonzero). */
133
+ outcome: 'ok' | 'changed' | 'failed';
134
+ detail: string;
135
+ }
136
+ export interface SetupResult {
137
+ steps: SetupStep[];
138
+ /** Every step landed on `ok`/`changed` and exactly one helper is running. */
139
+ configured: boolean;
140
+ status: MenubarStatus;
141
+ }
142
+ /**
143
+ * Decide which live helper processes must be ended so exactly one status item
144
+ * survives. Pure so the choice is unit-testable without a live menu bar.
145
+ *
146
+ * EVERY current process is ended, including the wanted one: the caller
147
+ * re-kickstarts the launchd service straight after, so the survivor is the one
148
+ * launchd owns (RunAtLoad + KeepAlive), not whichever copy happened to win a
149
+ * race. Picking a survivor from a `ps` listing cannot do this — the list says
150
+ * nothing about which pid launchd will keep alive, so leaving one alive risks
151
+ * keeping the un-managed copy and re-creating the duplicate on next login.
152
+ */
153
+ export declare function processesToEnd(status: Pick<MenubarStatus, 'instances' | 'foreignInstances'>): MenubarProcess[];
154
+ /**
155
+ * `agents menubar setup` — configure the menu bar end-to-end, idempotently.
156
+ *
157
+ * The one command that gets a machine to the intended state: exactly one status
158
+ * item, owned by a launchd service that starts it at login and restarts it if it
159
+ * dies. Each concern is a reported step, so a partial failure names itself
160
+ * instead of hiding behind "enabled".
161
+ *
162
+ * 1. bundle — install/refresh the .app at the stable App Support path
163
+ * 2. signature — a valid code identity (macOS 26+ SIGKILLs an invalid one)
164
+ * 3. duplicates — end every live helper, so the only survivor is launchd's
165
+ * 4. login item — write the plist (RunAtLoad + KeepAlive) and bootstrap it
166
+ * 5. single — verify exactly one helper came back up
167
+ */
168
+ export declare function runMenubarSetup(): SetupResult;
127
169
  /** A live MenubarHelper process: its pid and the executable it is running. */
128
170
  export interface MenubarProcess {
129
171
  pid: number;
@@ -131,19 +173,26 @@ export interface MenubarProcess {
131
173
  }
132
174
  /**
133
175
  * Split the live MenubarHelper processes into the installed bundle's own
134
- * (`running`) and every other copy (`foreign`).
176
+ * (`own`) and every other copy (`foreign`).
135
177
  *
136
178
  * `pgrep -f MenubarHelper` conflated the two, so a stray dev build could hold
137
179
  * the global Cmd-Shift-V chord (RegisterEventHotKey is first-come) while status
138
180
  * still reported a healthy `running: yes` — the paste was dead and nothing said
139
181
  * so. A foreign copy is the thing to look for, so name it.
140
182
  *
183
+ * `own` is a LIST, not a boolean: two copies of the INSTALLED bundle can run at
184
+ * once (launchd's KeepAlive service plus a LaunchServices/`open` launch of the
185
+ * same .app), which is the duplicate the user actually sees — two agents marks
186
+ * in the menu bar. Collapsing them to `running: true` reported that state as
187
+ * healthy. The helper now refuses to be the second (SingleInstance.swift), and
188
+ * `agents menubar setup` ends any duplicate a pre-fix helper left behind.
189
+ *
141
190
  * Identity comes from `comm` (the resolved executable), never from a substring
142
191
  * of the command line: matching the latter flags any shell that merely mentions
143
192
  * MenubarHelper. `command` is consulted only to drop `--notify` one-shots.
144
193
  */
145
194
  export declare function classifyMenubarProcesses(commOutput: string, commandOutput: string, installedExec: string): {
146
- running: boolean;
195
+ own: MenubarProcess[];
147
196
  foreign: MenubarProcess[];
148
197
  };
149
198
  export interface MenubarStatus {
@@ -155,6 +204,8 @@ export interface MenubarStatus {
155
204
  stale: boolean;
156
205
  serviceInstalled: boolean;
157
206
  running: boolean;
207
+ /** Live processes of the INSTALLED bundle. More than one is the duplicate. */
208
+ instances: MenubarProcess[];
158
209
  /** Live MenubarHelper processes that are NOT the installed bundle. */
159
210
  foreignInstances: MenubarProcess[];
160
211
  disabledByUser: boolean;