@phnx-labs/agents-cli 1.20.88 → 1.20.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +503 -0
- package/README.md +15 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/commands.js +7 -7
- package/dist/commands/exec.js +7 -1
- package/dist/commands/factory.js +26 -2
- package/dist/commands/funnel.js +16 -1
- package/dist/commands/harness.d.ts +27 -0
- package/dist/commands/harness.js +120 -13
- package/dist/commands/menubar.js +117 -34
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/routines.d.ts +19 -0
- package/dist/commands/routines.js +51 -7
- package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
- package/dist/commands/secrets-rotate-passphrase.js +96 -0
- package/dist/commands/secrets.d.ts +10 -1
- package/dist/commands/secrets.js +20 -6
- package/dist/commands/sessions-browser.d.ts +4 -0
- package/dist/commands/sessions-browser.js +51 -9
- package/dist/commands/sessions-favorite.d.ts +20 -0
- package/dist/commands/sessions-favorite.js +120 -0
- package/dist/commands/sessions.d.ts +110 -21
- package/dist/commands/sessions.js +395 -74
- package/dist/commands/setup-secrets.d.ts +7 -0
- package/dist/commands/setup-secrets.js +12 -9
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.d.ts +14 -1
- package/dist/commands/view.js +103 -128
- package/dist/commands/webhook.js +7 -2
- package/dist/lib/agents.d.ts +4 -2
- package/dist/lib/agents.js +21 -6
- package/dist/lib/commands.js +9 -1
- package/dist/lib/daemon.d.ts +29 -0
- package/dist/lib/daemon.js +58 -4
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/factory/snapshot.d.ts +78 -0
- package/dist/lib/factory/snapshot.js +209 -0
- package/dist/lib/fs-atomic.d.ts +14 -1
- package/dist/lib/fs-atomic.js +35 -3
- package/dist/lib/funnel.d.ts +1 -0
- package/dist/lib/funnel.js +8 -0
- package/dist/lib/hosts/dispatch.js +19 -1
- package/dist/lib/hq/floor.js +12 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/menubar/install-menubar.d.ts +53 -2
- package/dist/lib/menubar/install-menubar.js +183 -28
- package/dist/lib/picker.d.ts +27 -2
- package/dist/lib/picker.js +71 -7
- package/dist/lib/platform/process.d.ts +2 -0
- package/dist/lib/platform/process.js +5 -3
- package/dist/lib/profiles.d.ts +48 -0
- package/dist/lib/profiles.js +67 -0
- package/dist/lib/resources.d.ts +8 -0
- package/dist/lib/resources.js +34 -1
- package/dist/lib/rotate.d.ts +24 -2
- package/dist/lib/rotate.js +63 -6
- package/dist/lib/routines-placement.d.ts +2 -1
- package/dist/lib/routines-placement.js +8 -4
- package/dist/lib/routines.d.ts +57 -1
- package/dist/lib/routines.js +74 -1
- package/dist/lib/runner.d.ts +2 -0
- package/dist/lib/runner.js +21 -8
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +9 -34
- package/dist/lib/secrets/filestore.d.ts +152 -34
- package/dist/lib/secrets/filestore.js +676 -123
- package/dist/lib/session/active.d.ts +109 -3
- package/dist/lib/session/active.js +269 -13
- package/dist/lib/session/db.d.ts +14 -0
- package/dist/lib/session/db.js +35 -0
- package/dist/lib/session/favorites.d.ts +39 -0
- package/dist/lib/session/favorites.js +101 -0
- package/dist/lib/session/host-link.d.ts +68 -0
- package/dist/lib/session/host-link.js +64 -0
- package/dist/lib/session/presence.d.ts +85 -0
- package/dist/lib/session/presence.js +150 -0
- package/dist/lib/session/remote-active.d.ts +4 -1
- package/dist/lib/session/remote-active.js +8 -2
- package/dist/lib/session/remote-list.d.ts +10 -0
- package/dist/lib/session/remote-list.js +47 -9
- package/dist/lib/session/viewing-in.d.ts +31 -0
- package/dist/lib/session/viewing-in.js +47 -0
- package/dist/lib/state.d.ts +17 -0
- package/dist/lib/state.js +30 -2
- package/dist/lib/tmux/binary.d.ts +7 -0
- package/dist/lib/tmux/binary.js +11 -1
- package/dist/lib/triggers/handlers.d.ts +95 -0
- package/dist/lib/triggers/handlers.js +384 -0
- package/dist/lib/triggers/webhook.d.ts +10 -2
- package/dist/lib/triggers/webhook.js +65 -11
- package/dist/lib/types.d.ts +4 -3
- package/dist/lib/usage-backoff.d.ts +29 -0
- package/dist/lib/usage-backoff.js +165 -0
- package/dist/lib/usage.d.ts +112 -5
- package/dist/lib/usage.js +464 -46
- package/dist/lib/watchdog/runner.d.ts +13 -0
- package/dist/lib/watchdog/runner.js +16 -1
- package/package.json +1 -1
|
@@ -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
|
-
* (`
|
|
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
|
-
|
|
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;
|
|
@@ -19,6 +19,7 @@ import { execFileSync, spawnSync } from 'child_process';
|
|
|
19
19
|
import * as fs from 'fs';
|
|
20
20
|
import * as os from 'os';
|
|
21
21
|
import * as path from 'path';
|
|
22
|
+
import { sleepSync } from '../fs-atomic.js';
|
|
22
23
|
import { getRuntimeStateDir, getHelpersDir } from '../state.js';
|
|
23
24
|
import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../version.js';
|
|
24
25
|
const APP_BUNDLE_NAME = 'MenubarHelper.app';
|
|
@@ -149,6 +150,27 @@ function copyAppBundle(src, dest) {
|
|
|
149
150
|
throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
|
|
150
151
|
}
|
|
151
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Register the freshly-installed bundle with LaunchServices.
|
|
155
|
+
*
|
|
156
|
+
* The helper is copied to ~/Library/Application Support (not /Applications) and
|
|
157
|
+
* launched only via launchd, so LaunchServices may never discover it on its own.
|
|
158
|
+
* A daemon notification posted by the one-shot `MenubarHelper --notify` process is
|
|
159
|
+
* attributed to this bundle, and macOS resolves the notification's LEFT-hand app
|
|
160
|
+
* icon from the bundle's LaunchServices record — so a bundle LS doesn't know about
|
|
161
|
+
* shows a blank app icon there (the right-hand contentImage is unaffected;
|
|
162
|
+
* appIconImage reads the `.icns` directly). `lsregister -f` registers the bundle at
|
|
163
|
+
* its current path so the OS can resolve its AppIcon for that slot. Best-effort:
|
|
164
|
+
* LaunchServices is advisory, and a failure here must never block install.
|
|
165
|
+
*/
|
|
166
|
+
function refreshBundleIconRegistration(appPath) {
|
|
167
|
+
const lsregister = '/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister';
|
|
168
|
+
const bin = fs.existsSync(lsregister) ? lsregister : 'lsregister';
|
|
169
|
+
const r = spawnSync(bin, ['-f', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
170
|
+
if (r.error) {
|
|
171
|
+
/* lsregister missing / moved — advisory only, ignore. */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
152
174
|
/** True when the bundle carries a signature the kernel will accept at launch. */
|
|
153
175
|
export function codesignVerifies(appPath) {
|
|
154
176
|
const r = spawnSync('codesign', ['--verify', '--strict', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
@@ -200,6 +222,10 @@ export function ensureMenubarAppInstalled(opts = {}) {
|
|
|
200
222
|
}
|
|
201
223
|
copyAppBundle(src, dest);
|
|
202
224
|
ensureValidSignature(dest);
|
|
225
|
+
// A fresh copy is exactly when the bundle's icon can be new (first install) or
|
|
226
|
+
// superseded (upgrade) — register it so LaunchServices knows the bundle and can
|
|
227
|
+
// resolve its AppIcon for the left-hand slot of daemon notifications.
|
|
228
|
+
refreshBundleIconRegistration(dest);
|
|
203
229
|
return installedExecutablePath();
|
|
204
230
|
}
|
|
205
231
|
function xmlEscape(s) {
|
|
@@ -299,24 +325,34 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
|
|
|
299
325
|
process.stderr.write('agents: menu-bar helper has no valid code signature; skipping launch to avoid a crash loop.\n');
|
|
300
326
|
return false;
|
|
301
327
|
}
|
|
302
|
-
if (opts.clearOptOut)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
328
|
+
if (opts.clearOptOut)
|
|
329
|
+
clearMenubarOptOut();
|
|
330
|
+
installAndStartService(exec);
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
/** Drop the sticky `agents menubar disable` sentinel. */
|
|
334
|
+
function clearMenubarOptOut() {
|
|
335
|
+
try {
|
|
336
|
+
fs.rmSync(disabledSentinelPath(), { force: true });
|
|
307
337
|
}
|
|
338
|
+
catch { /* already gone */ }
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Write the launchd plist for `exec`, restart the job, and stamp the installed
|
|
342
|
+
* version. Shared by `enableMenubarService` and `runMenubarSetup` so the two
|
|
343
|
+
* cannot drift on what "installed and started" means — the version stamp in
|
|
344
|
+
* particular is what the upgrade self-heal reads to decide staleness, and a
|
|
345
|
+
* path that skipped it would make every later `agents` invocation reinstall.
|
|
346
|
+
*/
|
|
347
|
+
function installAndStartService(exec) {
|
|
308
348
|
const plist = servicePlistPath();
|
|
309
349
|
fs.mkdirSync(path.dirname(plist), { recursive: true });
|
|
310
350
|
fs.writeFileSync(plist, generateServicePlist(exec));
|
|
311
|
-
|
|
312
|
-
restartMenubarLaunchAgent(uid, plist);
|
|
313
|
-
// Stamp the version we just installed so the upgrade self-heal can tell when
|
|
314
|
-
// a later release ships a newer helper that needs reinstalling.
|
|
351
|
+
restartMenubarLaunchAgent(process.getuid?.() ?? 0, plist);
|
|
315
352
|
try {
|
|
316
353
|
fs.writeFileSync(installedVersionMarkerPath(), getCliVersion());
|
|
317
354
|
}
|
|
318
355
|
catch { /* best effort */ }
|
|
319
|
-
return true;
|
|
320
356
|
}
|
|
321
357
|
/**
|
|
322
358
|
* Pure staleness decision (no I/O) so the truth table is unit-testable. The
|
|
@@ -441,6 +477,117 @@ export function installMenubarLaunchAgentOnUpgrade() {
|
|
|
441
477
|
/* never block startup on the menu bar */
|
|
442
478
|
}
|
|
443
479
|
}
|
|
480
|
+
/**
|
|
481
|
+
* Decide which live helper processes must be ended so exactly one status item
|
|
482
|
+
* survives. Pure so the choice is unit-testable without a live menu bar.
|
|
483
|
+
*
|
|
484
|
+
* EVERY current process is ended, including the wanted one: the caller
|
|
485
|
+
* re-kickstarts the launchd service straight after, so the survivor is the one
|
|
486
|
+
* launchd owns (RunAtLoad + KeepAlive), not whichever copy happened to win a
|
|
487
|
+
* race. Picking a survivor from a `ps` listing cannot do this — the list says
|
|
488
|
+
* nothing about which pid launchd will keep alive, so leaving one alive risks
|
|
489
|
+
* keeping the un-managed copy and re-creating the duplicate on next login.
|
|
490
|
+
*/
|
|
491
|
+
export function processesToEnd(status) {
|
|
492
|
+
return [...status.instances, ...status.foreignInstances];
|
|
493
|
+
}
|
|
494
|
+
function endProcess(pid) {
|
|
495
|
+
try {
|
|
496
|
+
process.kill(pid, 'SIGTERM');
|
|
497
|
+
}
|
|
498
|
+
catch { /* already gone */ }
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* `agents menubar setup` — configure the menu bar end-to-end, idempotently.
|
|
502
|
+
*
|
|
503
|
+
* The one command that gets a machine to the intended state: exactly one status
|
|
504
|
+
* item, owned by a launchd service that starts it at login and restarts it if it
|
|
505
|
+
* dies. Each concern is a reported step, so a partial failure names itself
|
|
506
|
+
* instead of hiding behind "enabled".
|
|
507
|
+
*
|
|
508
|
+
* 1. bundle — install/refresh the .app at the stable App Support path
|
|
509
|
+
* 2. signature — a valid code identity (macOS 26+ SIGKILLs an invalid one)
|
|
510
|
+
* 3. duplicates — end every live helper, so the only survivor is launchd's
|
|
511
|
+
* 4. login item — write the plist (RunAtLoad + KeepAlive) and bootstrap it
|
|
512
|
+
* 5. single — verify exactly one helper came back up
|
|
513
|
+
*/
|
|
514
|
+
export function runMenubarSetup() {
|
|
515
|
+
const steps = [];
|
|
516
|
+
const step = (name, outcome, detail) => {
|
|
517
|
+
steps.push({ name, outcome, detail });
|
|
518
|
+
};
|
|
519
|
+
if (!onDarwin()) {
|
|
520
|
+
step('platform', 'failed', `the menu bar helper is macOS only (this is ${process.platform})`);
|
|
521
|
+
return { steps, configured: false, status: getMenubarStatus() };
|
|
522
|
+
}
|
|
523
|
+
const before = getMenubarStatus();
|
|
524
|
+
if (!sourceAppPath()) {
|
|
525
|
+
step('bundle', 'failed', 'no menu-bar helper bundle ships with this install');
|
|
526
|
+
return { steps, configured: false, status: before };
|
|
527
|
+
}
|
|
528
|
+
// 3 before 1: end the running copies BEFORE swapping the bundle underneath
|
|
529
|
+
// them, so no helper keeps a status item alive on a binary that no longer
|
|
530
|
+
// exists on disk.
|
|
531
|
+
const doomed = processesToEnd(before);
|
|
532
|
+
for (const p of doomed)
|
|
533
|
+
endProcess(p.pid);
|
|
534
|
+
if (doomed.length > 1) {
|
|
535
|
+
step('duplicates', 'changed', `ended ${doomed.length} running helpers (${doomed.map((p) => p.pid).join(', ')}) — launchd restarts exactly one`);
|
|
536
|
+
}
|
|
537
|
+
else if (doomed.length === 1) {
|
|
538
|
+
step('duplicates', 'ok', 'one helper was running; restarting it under launchd');
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
step('duplicates', 'ok', 'no helper was running');
|
|
542
|
+
}
|
|
543
|
+
const exec = ensureMenubarAppInstalled({ forceReinstall: true });
|
|
544
|
+
if (!exec) {
|
|
545
|
+
step('bundle', 'failed', 'could not install the helper bundle');
|
|
546
|
+
return { steps, configured: false, status: getMenubarStatus() };
|
|
547
|
+
}
|
|
548
|
+
step('bundle', before.installedVersion === getCliVersion() ? 'ok' : 'changed', `${installedAppPath()} (${getCliVersion()})`);
|
|
549
|
+
if (!codesignVerifies(installedAppPath())) {
|
|
550
|
+
step('signature', 'failed', 'no valid code signature — refusing to start it (launchd KeepAlive would crash-loop)');
|
|
551
|
+
return { steps, configured: false, status: getMenubarStatus() };
|
|
552
|
+
}
|
|
553
|
+
step('signature', 'ok', 'valid');
|
|
554
|
+
// Clear the sticky opt-out: running `setup` is an explicit request for the
|
|
555
|
+
// menu bar, so a stale `menubar disable` must not silently win.
|
|
556
|
+
clearMenubarOptOut();
|
|
557
|
+
installAndStartService(exec);
|
|
558
|
+
step('login item', before.serviceInstalled ? 'ok' : 'changed', `${SERVICE_LABEL} — starts at login, restarts if it dies`);
|
|
559
|
+
// launchd's bootstrap+kickstart is asynchronous; give the status item a beat
|
|
560
|
+
// to claim the lock before counting instances, or `setup` reports zero on a
|
|
561
|
+
// machine that is in fact coming up correctly.
|
|
562
|
+
const after = waitForSingleInstance();
|
|
563
|
+
if (after.instances.length === 1 && after.foreignInstances.length === 0) {
|
|
564
|
+
step('single instance', 'ok', `pid ${after.instances[0].pid}`);
|
|
565
|
+
}
|
|
566
|
+
else if (after.instances.length === 0) {
|
|
567
|
+
step('single instance', 'failed', 'the helper did not come back up — see `agents menubar status`');
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
const extra = [...after.instances.slice(1), ...after.foreignInstances];
|
|
571
|
+
step('single instance', 'failed', `${after.instances.length + after.foreignInstances.length} helpers running (${extra.map((p) => p.pid).join(', ')} are extra)`);
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
steps,
|
|
575
|
+
configured: steps.every((s) => s.outcome !== 'failed'),
|
|
576
|
+
status: after,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Poll (up to ~3s) for launchd to bring the single helper back. Returns the
|
|
581
|
+
* last status read either way — the caller decides what a miss means.
|
|
582
|
+
*/
|
|
583
|
+
function waitForSingleInstance() {
|
|
584
|
+
let status = getMenubarStatus();
|
|
585
|
+
for (let i = 0; i < 15 && status.instances.length !== 1; i++) {
|
|
586
|
+
sleepSync(200);
|
|
587
|
+
status = getMenubarStatus();
|
|
588
|
+
}
|
|
589
|
+
return status;
|
|
590
|
+
}
|
|
444
591
|
/** Parse `ps -axo pid=,<field>=` into pid -> field. The field is the rest of the
|
|
445
592
|
* line, so a path containing spaces (App Support does) survives intact. */
|
|
446
593
|
function parsePsLines(psOutput) {
|
|
@@ -454,21 +601,28 @@ function parsePsLines(psOutput) {
|
|
|
454
601
|
}
|
|
455
602
|
/**
|
|
456
603
|
* Split the live MenubarHelper processes into the installed bundle's own
|
|
457
|
-
* (`
|
|
604
|
+
* (`own`) and every other copy (`foreign`).
|
|
458
605
|
*
|
|
459
606
|
* `pgrep -f MenubarHelper` conflated the two, so a stray dev build could hold
|
|
460
607
|
* the global Cmd-Shift-V chord (RegisterEventHotKey is first-come) while status
|
|
461
608
|
* still reported a healthy `running: yes` — the paste was dead and nothing said
|
|
462
609
|
* so. A foreign copy is the thing to look for, so name it.
|
|
463
610
|
*
|
|
611
|
+
* `own` is a LIST, not a boolean: two copies of the INSTALLED bundle can run at
|
|
612
|
+
* once (launchd's KeepAlive service plus a LaunchServices/`open` launch of the
|
|
613
|
+
* same .app), which is the duplicate the user actually sees — two agents marks
|
|
614
|
+
* in the menu bar. Collapsing them to `running: true` reported that state as
|
|
615
|
+
* healthy. The helper now refuses to be the second (SingleInstance.swift), and
|
|
616
|
+
* `agents menubar setup` ends any duplicate a pre-fix helper left behind.
|
|
617
|
+
*
|
|
464
618
|
* Identity comes from `comm` (the resolved executable), never from a substring
|
|
465
619
|
* of the command line: matching the latter flags any shell that merely mentions
|
|
466
620
|
* MenubarHelper. `command` is consulted only to drop `--notify` one-shots.
|
|
467
621
|
*/
|
|
468
622
|
export function classifyMenubarProcesses(commOutput, commandOutput, installedExec) {
|
|
469
623
|
const commands = parsePsLines(commandOutput);
|
|
624
|
+
const own = [];
|
|
470
625
|
const foreign = [];
|
|
471
|
-
let running = false;
|
|
472
626
|
for (const [pid, executable] of parsePsLines(commOutput)) {
|
|
473
627
|
if (path.basename(executable) !== 'MenubarHelper')
|
|
474
628
|
continue;
|
|
@@ -477,26 +631,26 @@ export function classifyMenubarProcesses(commOutput, commandOutput, installedExe
|
|
|
477
631
|
if ((commands.get(pid) || '').includes('--notify'))
|
|
478
632
|
continue;
|
|
479
633
|
if (executable === installedExec)
|
|
480
|
-
|
|
634
|
+
own.push({ pid, executable });
|
|
481
635
|
else
|
|
482
636
|
foreign.push({ pid, executable });
|
|
483
637
|
}
|
|
484
|
-
return {
|
|
638
|
+
return { own, foreign };
|
|
639
|
+
}
|
|
640
|
+
/** Live MenubarHelper processes, split by whether they are the installed bundle. */
|
|
641
|
+
function liveMenubarProcesses() {
|
|
642
|
+
if (!onDarwin())
|
|
643
|
+
return { own: [], foreign: [] };
|
|
644
|
+
const ps = (format) => spawnSync('ps', ['-axo', format], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
|
|
645
|
+
const comm = ps('pid=,comm=');
|
|
646
|
+
const command = ps('pid=,command=');
|
|
647
|
+
if (comm.status !== 0 || command.status !== 0)
|
|
648
|
+
return { own: [], foreign: [] };
|
|
649
|
+
return classifyMenubarProcesses(comm.stdout || '', command.stdout || '', installedExecutablePath());
|
|
485
650
|
}
|
|
486
651
|
export function getMenubarStatus() {
|
|
487
652
|
const dest = installedAppPath();
|
|
488
|
-
|
|
489
|
-
let foreignInstances = [];
|
|
490
|
-
if (onDarwin()) {
|
|
491
|
-
const ps = (format) => spawnSync('ps', ['-axo', format], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
|
|
492
|
-
const comm = ps('pid=,comm=');
|
|
493
|
-
const command = ps('pid=,command=');
|
|
494
|
-
if (comm.status === 0 && command.status === 0) {
|
|
495
|
-
const c = classifyMenubarProcesses(comm.stdout || '', command.stdout || '', installedExecutablePath());
|
|
496
|
-
running = c.running;
|
|
497
|
-
foreignInstances = c.foreign;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
653
|
+
const { own, foreign } = liveMenubarProcesses();
|
|
500
654
|
const serviceInstalled = menubarServiceInstalled();
|
|
501
655
|
return {
|
|
502
656
|
platform: process.platform,
|
|
@@ -506,8 +660,9 @@ export function getMenubarStatus() {
|
|
|
506
660
|
currentVersion: getCliVersion(),
|
|
507
661
|
stale: onDarwin() && serviceInstalled && menubarSetupStale(),
|
|
508
662
|
serviceInstalled,
|
|
509
|
-
running,
|
|
510
|
-
|
|
663
|
+
running: own.length > 0,
|
|
664
|
+
instances: own,
|
|
665
|
+
foreignInstances: foreign,
|
|
511
666
|
disabledByUser: menubarDisabledByUser(),
|
|
512
667
|
};
|
|
513
668
|
}
|
package/dist/lib/picker.d.ts
CHANGED
|
@@ -77,9 +77,14 @@ export interface DynamicPickerConfig<T, F> {
|
|
|
77
77
|
/**
|
|
78
78
|
* Side-effecting keys that don't change the filter (e.g. `y` copies a command).
|
|
79
79
|
* Receives the live search `query` so the effect can be search-aware. Return a
|
|
80
|
-
* short string to flash under the list
|
|
80
|
+
* short string to flash under the list, or `{ flash, reload }` when the effect
|
|
81
|
+
* changed something the rows RENDER (a star, a mark) and the list has to be
|
|
82
|
+
* rebuilt — the row labels are memoized, so a flash alone leaves them stale.
|
|
81
83
|
*/
|
|
82
|
-
onKey?: (name: string, filter: F, active: T | undefined, query: string) => string | void
|
|
84
|
+
onKey?: (name: string, filter: F, active: T | undefined, query: string) => string | void | {
|
|
85
|
+
flash?: string;
|
|
86
|
+
reload?: boolean;
|
|
87
|
+
};
|
|
83
88
|
/** Key that enters search mode (default `s`). */
|
|
84
89
|
searchKey?: string;
|
|
85
90
|
/** Key that toggles the preview pane (default `tab`). */
|
|
@@ -89,6 +94,26 @@ export interface DynamicPickerConfig<T, F> {
|
|
|
89
94
|
loadingMessage?: string;
|
|
90
95
|
enterHint?: string;
|
|
91
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* The lookup token for a hotkey: the literal character the key produced, else
|
|
99
|
+
* readline's key name (`tab`, `escape`, arrows).
|
|
100
|
+
*
|
|
101
|
+
* readline reports both `f` and `F` as name `f` — only `sequence` tells them
|
|
102
|
+
* apart — and gives a punctuation key like `*` no name at all. Keying on the
|
|
103
|
+
* character makes shifted letters and punctuation bindable, and is a no-op for
|
|
104
|
+
* every existing binding: for a plain lowercase letter, sequence === name.
|
|
105
|
+
*
|
|
106
|
+
* Callers receiving this in `onKey` see the literal character, so a handler that
|
|
107
|
+
* wants to accept both cases of a letter must say so (`'y'` and `'Y'`); the
|
|
108
|
+
* keyBindings lookup falls back to the key NAME, which keeps the shifted form of
|
|
109
|
+
* an existing single-letter binding working without each caller restating it.
|
|
110
|
+
*/
|
|
111
|
+
export declare function hotkeyToken(key: {
|
|
112
|
+
name?: string;
|
|
113
|
+
sequence?: string;
|
|
114
|
+
ctrl?: boolean;
|
|
115
|
+
meta?: boolean;
|
|
116
|
+
}): string;
|
|
92
117
|
/** The result returned when the user selects a row: the item plus the live filter. */
|
|
93
118
|
export interface DynamicPicked<T, F> {
|
|
94
119
|
item: T;
|
package/dist/lib/picker.js
CHANGED
|
@@ -324,6 +324,28 @@ export function multiItemPicker(config) {
|
|
|
324
324
|
});
|
|
325
325
|
return prompt(config);
|
|
326
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* The lookup token for a hotkey: the literal character the key produced, else
|
|
329
|
+
* readline's key name (`tab`, `escape`, arrows).
|
|
330
|
+
*
|
|
331
|
+
* readline reports both `f` and `F` as name `f` — only `sequence` tells them
|
|
332
|
+
* apart — and gives a punctuation key like `*` no name at all. Keying on the
|
|
333
|
+
* character makes shifted letters and punctuation bindable, and is a no-op for
|
|
334
|
+
* every existing binding: for a plain lowercase letter, sequence === name.
|
|
335
|
+
*
|
|
336
|
+
* Callers receiving this in `onKey` see the literal character, so a handler that
|
|
337
|
+
* wants to accept both cases of a letter must say so (`'y'` and `'Y'`); the
|
|
338
|
+
* keyBindings lookup falls back to the key NAME, which keeps the shifted form of
|
|
339
|
+
* an existing single-letter binding working without each caller restating it.
|
|
340
|
+
*/
|
|
341
|
+
export function hotkeyToken(key) {
|
|
342
|
+
const seq = key.sequence;
|
|
343
|
+
// Printable single characters only — a control code's sequence (`\t`, `\r`,
|
|
344
|
+
// `\x7f`) must keep resolving to its name.
|
|
345
|
+
if (!key.ctrl && !key.meta && seq && seq.length === 1 && seq > ' ' && seq !== '\x7f')
|
|
346
|
+
return seq;
|
|
347
|
+
return key.name ?? '';
|
|
348
|
+
}
|
|
327
349
|
/**
|
|
328
350
|
* Async-refetch variant of {@link itemPicker}. Holds a `filter` object in state and
|
|
329
351
|
* re-runs `load(filter)` whenever a keybinding mutates it (with a loading placeholder
|
|
@@ -349,11 +371,32 @@ export function dynamicPicker(config) {
|
|
|
349
371
|
const [previewOpen, setPreviewOpen] = useState(Boolean(cfg.buildPreview));
|
|
350
372
|
const [active, setActive] = useState(0);
|
|
351
373
|
const [flash, setFlash] = useState('');
|
|
374
|
+
// Bumped by an `onKey` that asks for a reload; a dep of the load effect, so a
|
|
375
|
+
// side effect that changed the rows can rebuild them without a filter change.
|
|
376
|
+
const [reloadNonce, setReloadNonce] = useState(0);
|
|
377
|
+
// The counter lives in a ref, not in the state read back from the keypress
|
|
378
|
+
// closure: that closure can hold a STALE `reloadNonce`, so a second reload
|
|
379
|
+
// would recompute the same value, the state would not change, and the
|
|
380
|
+
// repaint would silently never happen (the first star appeared, the second
|
|
381
|
+
// did not). A ref is always current.
|
|
382
|
+
const reloadCount = useRef(0);
|
|
383
|
+
// Bumped when a load RESOLVES. `load` is async, so the render that follows
|
|
384
|
+
// `setReloadNonce` still sees the pre-load data — memoizing the row labels on
|
|
385
|
+
// the nonce alone rendered each press's result one press late. Keying them on
|
|
386
|
+
// load COMPLETION is what actually makes them current.
|
|
387
|
+
const [loadedSeq, setLoadedSeq] = useState(0);
|
|
388
|
+
const loadedCount = useRef(0);
|
|
352
389
|
const prefix = usePrefix({ status, theme });
|
|
353
390
|
// Guards against a slow load resolving after a newer filter superseded it.
|
|
354
391
|
const gen = useRef(0);
|
|
392
|
+
// The filter the last load ran on, so a nonce-only reload (a row's own state
|
|
393
|
+
// changed) can keep the cursor where the user left it. Snapping back to the
|
|
394
|
+
// top every time you star a row would make the key unusable for a second one.
|
|
395
|
+
const loadedFilter = useRef(undefined);
|
|
355
396
|
useEffect(() => {
|
|
356
397
|
const my = ++gen.current;
|
|
398
|
+
const filterChanged = loadedFilter.current !== filter;
|
|
399
|
+
loadedFilter.current = filter;
|
|
357
400
|
setLoading(true);
|
|
358
401
|
Promise.resolve(cfg.load(filter))
|
|
359
402
|
.then((rows) => {
|
|
@@ -361,7 +404,9 @@ export function dynamicPicker(config) {
|
|
|
361
404
|
return;
|
|
362
405
|
setItems(rows);
|
|
363
406
|
setLoading(false);
|
|
364
|
-
|
|
407
|
+
setLoadedSeq((loadedCount.current += 1));
|
|
408
|
+
if (filterChanged)
|
|
409
|
+
setActive(0);
|
|
365
410
|
})
|
|
366
411
|
.catch(() => {
|
|
367
412
|
if (my !== gen.current)
|
|
@@ -369,7 +414,7 @@ export function dynamicPicker(config) {
|
|
|
369
414
|
setItems([]);
|
|
370
415
|
setLoading(false);
|
|
371
416
|
});
|
|
372
|
-
}, [filter]);
|
|
417
|
+
}, [filter, reloadNonce]);
|
|
373
418
|
const results = useMemo(() => {
|
|
374
419
|
const q = query.trim();
|
|
375
420
|
const pool = q && cfg.matches ? items.filter((it) => cfg.matches(it, q)) : items;
|
|
@@ -377,7 +422,12 @@ export function dynamicPicker(config) {
|
|
|
377
422
|
value: item,
|
|
378
423
|
label: cfg.labelFor(item, q),
|
|
379
424
|
}));
|
|
380
|
-
|
|
425
|
+
// `loadedSeq` is a dep because a reload can legitimately return the SAME
|
|
426
|
+
// array — `load` hands back its cached pool unchanged when no filter is
|
|
427
|
+
// active — while what a row RENDERS has changed underneath it. Without this
|
|
428
|
+
// the labels stay memoized on the old state and the side effect looks like
|
|
429
|
+
// it silently did nothing (starring a row left the star invisible).
|
|
430
|
+
}, [items, query, loadedSeq]);
|
|
381
431
|
useEffect(() => {
|
|
382
432
|
if (active >= results.length)
|
|
383
433
|
setActive(0);
|
|
@@ -462,7 +512,13 @@ export function dynamicPicker(config) {
|
|
|
462
512
|
setPreviewOpen(!previewOpen);
|
|
463
513
|
return;
|
|
464
514
|
}
|
|
465
|
-
const
|
|
515
|
+
const token = hotkeyToken(key);
|
|
516
|
+
// Exact character first (so `*` and a shifted `F` are addressable), then
|
|
517
|
+
// the readline name. The fallback is what preserves the shifted form of an
|
|
518
|
+
// existing single-letter hotkey: `R`/`C`/`A` used to reach their bindings
|
|
519
|
+
// via `key.name`, and keying on the character alone would silently retire
|
|
520
|
+
// them for anyone with caps lock on.
|
|
521
|
+
const binding = cfg.keyBindings?.[token] ?? cfg.keyBindings?.[key.name ?? ''];
|
|
466
522
|
if (binding) {
|
|
467
523
|
const next = binding(filter);
|
|
468
524
|
if (!Object.is(next, filter))
|
|
@@ -470,9 +526,17 @@ export function dynamicPicker(config) {
|
|
|
470
526
|
return;
|
|
471
527
|
}
|
|
472
528
|
if (cfg.onKey) {
|
|
473
|
-
const
|
|
474
|
-
if (
|
|
475
|
-
setFlash(
|
|
529
|
+
const res = cfg.onKey(token, filter, selected?.value, query);
|
|
530
|
+
if (typeof res === 'string')
|
|
531
|
+
setFlash(res);
|
|
532
|
+
else if (res) {
|
|
533
|
+
if (res.flash)
|
|
534
|
+
setFlash(res.flash);
|
|
535
|
+
// The rows themselves changed — force the load effect to re-run so the
|
|
536
|
+
// memoized labels are rebuilt.
|
|
537
|
+
if (res.reload)
|
|
538
|
+
setReloadNonce((reloadCount.current += 1));
|
|
539
|
+
}
|
|
476
540
|
}
|
|
477
541
|
});
|
|
478
542
|
const message = theme.style.message(cfg.message, status);
|
|
@@ -34,9 +34,11 @@ export declare function killTree(pid: number): void;
|
|
|
34
34
|
* sites pass their own `windowsHide` with piped stdio.
|
|
35
35
|
*/
|
|
36
36
|
export declare function backgroundSpawnOptions(opts?: {
|
|
37
|
+
cwd?: string;
|
|
37
38
|
fdStdio?: boolean;
|
|
38
39
|
platform?: NodeJS.Platform;
|
|
39
40
|
}): {
|
|
41
|
+
cwd: string;
|
|
40
42
|
detached: boolean;
|
|
41
43
|
windowsHide: boolean;
|
|
42
44
|
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { execFileSync } from 'child_process';
|
|
5
5
|
import { readFileSync } from 'fs';
|
|
6
|
+
import * as os from 'os';
|
|
6
7
|
import { sleepSync } from '../fs-atomic.js';
|
|
7
8
|
/**
|
|
8
9
|
* Forcefully terminate a process AND its descendant tree.
|
|
@@ -56,12 +57,13 @@ export function killTree(pid) {
|
|
|
56
57
|
*/
|
|
57
58
|
export function backgroundSpawnOptions(opts = {}) {
|
|
58
59
|
const platform = opts.platform ?? process.platform;
|
|
60
|
+
const cwd = opts.cwd ?? os.homedir();
|
|
59
61
|
if (platform === 'win32') {
|
|
60
62
|
return opts.fdStdio
|
|
61
|
-
? { detached: true, windowsHide: true }
|
|
62
|
-
: { detached: false, windowsHide: true };
|
|
63
|
+
? { cwd, detached: true, windowsHide: true }
|
|
64
|
+
: { cwd, detached: false, windowsHide: true };
|
|
63
65
|
}
|
|
64
|
-
return { detached: true, windowsHide: false };
|
|
66
|
+
return { cwd, detached: true, windowsHide: false };
|
|
65
67
|
}
|
|
66
68
|
/**
|
|
67
69
|
* Is a process with this PID currently alive?
|