@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +503 -0
  2. package/README.md +15 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/exec.js +7 -1
  6. package/dist/commands/factory.js +26 -2
  7. package/dist/commands/funnel.js +16 -1
  8. package/dist/commands/harness.d.ts +27 -0
  9. package/dist/commands/harness.js +120 -13
  10. package/dist/commands/menubar.js +117 -34
  11. package/dist/commands/profiles.d.ts +3 -0
  12. package/dist/commands/profiles.js +1 -1
  13. package/dist/commands/routines.d.ts +19 -0
  14. package/dist/commands/routines.js +51 -7
  15. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  16. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  17. package/dist/commands/secrets.d.ts +10 -1
  18. package/dist/commands/secrets.js +20 -6
  19. package/dist/commands/sessions-browser.d.ts +4 -0
  20. package/dist/commands/sessions-browser.js +51 -9
  21. package/dist/commands/sessions-favorite.d.ts +20 -0
  22. package/dist/commands/sessions-favorite.js +120 -0
  23. package/dist/commands/sessions.d.ts +110 -21
  24. package/dist/commands/sessions.js +395 -74
  25. package/dist/commands/setup-secrets.d.ts +7 -0
  26. package/dist/commands/setup-secrets.js +12 -9
  27. package/dist/commands/versions.js +12 -4
  28. package/dist/commands/view.d.ts +14 -1
  29. package/dist/commands/view.js +103 -128
  30. package/dist/commands/webhook.js +7 -2
  31. package/dist/lib/agents.d.ts +4 -2
  32. package/dist/lib/agents.js +21 -6
  33. package/dist/lib/commands.js +9 -1
  34. package/dist/lib/daemon.d.ts +29 -0
  35. package/dist/lib/daemon.js +58 -4
  36. package/dist/lib/events.d.ts +1 -1
  37. package/dist/lib/factory/snapshot.d.ts +78 -0
  38. package/dist/lib/factory/snapshot.js +209 -0
  39. package/dist/lib/fs-atomic.d.ts +14 -1
  40. package/dist/lib/fs-atomic.js +35 -3
  41. package/dist/lib/funnel.d.ts +1 -0
  42. package/dist/lib/funnel.js +8 -0
  43. package/dist/lib/hosts/dispatch.js +19 -1
  44. package/dist/lib/hq/floor.js +12 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  47. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  48. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  49. package/dist/lib/menubar/install-menubar.js +183 -28
  50. package/dist/lib/picker.d.ts +27 -2
  51. package/dist/lib/picker.js +71 -7
  52. package/dist/lib/platform/process.d.ts +2 -0
  53. package/dist/lib/platform/process.js +5 -3
  54. package/dist/lib/profiles.d.ts +48 -0
  55. package/dist/lib/profiles.js +67 -0
  56. package/dist/lib/resources.d.ts +8 -0
  57. package/dist/lib/resources.js +34 -1
  58. package/dist/lib/rotate.d.ts +24 -2
  59. package/dist/lib/rotate.js +63 -6
  60. package/dist/lib/routines-placement.d.ts +2 -1
  61. package/dist/lib/routines-placement.js +8 -4
  62. package/dist/lib/routines.d.ts +57 -1
  63. package/dist/lib/routines.js +74 -1
  64. package/dist/lib/runner.d.ts +2 -0
  65. package/dist/lib/runner.js +21 -8
  66. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  67. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  68. package/dist/lib/secrets/bundles.js +9 -34
  69. package/dist/lib/secrets/filestore.d.ts +152 -34
  70. package/dist/lib/secrets/filestore.js +676 -123
  71. package/dist/lib/session/active.d.ts +109 -3
  72. package/dist/lib/session/active.js +269 -13
  73. package/dist/lib/session/db.d.ts +14 -0
  74. package/dist/lib/session/db.js +35 -0
  75. package/dist/lib/session/favorites.d.ts +39 -0
  76. package/dist/lib/session/favorites.js +101 -0
  77. package/dist/lib/session/host-link.d.ts +68 -0
  78. package/dist/lib/session/host-link.js +64 -0
  79. package/dist/lib/session/presence.d.ts +85 -0
  80. package/dist/lib/session/presence.js +150 -0
  81. package/dist/lib/session/remote-active.d.ts +4 -1
  82. package/dist/lib/session/remote-active.js +8 -2
  83. package/dist/lib/session/remote-list.d.ts +10 -0
  84. package/dist/lib/session/remote-list.js +47 -9
  85. package/dist/lib/session/viewing-in.d.ts +31 -0
  86. package/dist/lib/session/viewing-in.js +47 -0
  87. package/dist/lib/state.d.ts +17 -0
  88. package/dist/lib/state.js +30 -2
  89. package/dist/lib/tmux/binary.d.ts +7 -0
  90. package/dist/lib/tmux/binary.js +11 -1
  91. package/dist/lib/triggers/handlers.d.ts +95 -0
  92. package/dist/lib/triggers/handlers.js +384 -0
  93. package/dist/lib/triggers/webhook.d.ts +10 -2
  94. package/dist/lib/triggers/webhook.js +65 -11
  95. package/dist/lib/types.d.ts +4 -3
  96. package/dist/lib/usage-backoff.d.ts +29 -0
  97. package/dist/lib/usage-backoff.js +165 -0
  98. package/dist/lib/usage.d.ts +112 -5
  99. package/dist/lib/usage.js +464 -46
  100. package/dist/lib/watchdog/runner.d.ts +13 -0
  101. package/dist/lib/watchdog/runner.js +16 -1
  102. 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
- * (`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;
@@ -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
- try {
304
- fs.rmSync(disabledSentinelPath(), { force: true });
305
- }
306
- catch { /* already gone */ }
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
- const uid = process.getuid?.() ?? 0;
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
- * (`running`) and every other copy (`foreign`).
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
- running = true;
634
+ own.push({ pid, executable });
481
635
  else
482
636
  foreign.push({ pid, executable });
483
637
  }
484
- return { running, foreign };
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
- let running = false;
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
- foreignInstances,
663
+ running: own.length > 0,
664
+ instances: own,
665
+ foreignInstances: foreign,
511
666
  disabledByUser: menubarDisabledByUser(),
512
667
  };
513
668
  }
@@ -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;
@@ -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
- setActive(0);
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
- }, [items, query]);
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 binding = cfg.keyBindings?.[key.name ?? ''];
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 msg = cfg.onKey(key.name ?? '', filter, selected?.value, query);
474
- if (msg)
475
- setFlash(msg);
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?