@phnx-labs/agents-cli 1.22.28 → 1.22.30

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 (75) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +39 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.d.ts +13 -0
  5. package/dist/commands/accounts.js +32 -0
  6. package/dist/commands/daemon.d.ts +18 -0
  7. package/dist/commands/daemon.js +581 -0
  8. package/dist/commands/exec.js +66 -20
  9. package/dist/commands/routines.js +29 -11
  10. package/dist/commands/secrets.d.ts +17 -0
  11. package/dist/commands/secrets.js +30 -15
  12. package/dist/commands/sessions-browser.js +6 -6
  13. package/dist/commands/sessions-favorite.d.ts +7 -7
  14. package/dist/commands/sessions-favorite.js +30 -30
  15. package/dist/commands/sessions-picker.d.ts +33 -1
  16. package/dist/commands/sessions-picker.js +102 -27
  17. package/dist/commands/sessions.d.ts +12 -1
  18. package/dist/commands/sessions.js +259 -20
  19. package/dist/commands/view.d.ts +11 -0
  20. package/dist/commands/view.js +56 -29
  21. package/dist/index.js +37 -2
  22. package/dist/lib/account-labels.d.ts +24 -0
  23. package/dist/lib/account-labels.js +72 -0
  24. package/dist/lib/agents.d.ts +32 -1
  25. package/dist/lib/agents.js +96 -31
  26. package/dist/lib/daemon-health.d.ts +24 -0
  27. package/dist/lib/daemon-health.js +84 -0
  28. package/dist/lib/daemon-ticks.d.ts +81 -0
  29. package/dist/lib/daemon-ticks.js +190 -0
  30. package/dist/lib/daemon.d.ts +68 -18
  31. package/dist/lib/daemon.js +303 -338
  32. package/dist/lib/device-config.d.ts +10 -0
  33. package/dist/lib/device-config.js +27 -0
  34. package/dist/lib/exec.d.ts +27 -0
  35. package/dist/lib/exec.js +49 -2
  36. package/dist/lib/hosts/dispatch.d.ts +4 -0
  37. package/dist/lib/hosts/dispatch.js +4 -0
  38. package/dist/lib/hosts/remote-cmd.js +1 -0
  39. package/dist/lib/hosts/run-target.d.ts +1 -0
  40. package/dist/lib/hosts/run-target.js +1 -0
  41. package/dist/lib/import.js +7 -6
  42. package/dist/lib/memory-cache.d.ts +19 -0
  43. package/dist/lib/memory-cache.js +31 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  46. package/dist/lib/menubar/snapshot.d.ts +16 -0
  47. package/dist/lib/menubar/snapshot.js +22 -1
  48. package/dist/lib/migrate.d.ts +1 -1
  49. package/dist/lib/migrate.js +7 -2
  50. package/dist/lib/routine-activation.d.ts +2 -0
  51. package/dist/lib/routine-activation.js +16 -0
  52. package/dist/lib/runner.d.ts +18 -0
  53. package/dist/lib/runner.js +52 -0
  54. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  55. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  56. package/dist/lib/secrets/agent.d.ts +19 -0
  57. package/dist/lib/secrets/agent.js +32 -2
  58. package/dist/lib/secrets/scope.d.ts +3 -3
  59. package/dist/lib/secrets/scope.js +3 -3
  60. package/dist/lib/session/db.d.ts +15 -0
  61. package/dist/lib/session/db.js +90 -15
  62. package/dist/lib/session/discover.js +91 -39
  63. package/dist/lib/session/favorites.d.ts +2 -2
  64. package/dist/lib/session/favorites.js +2 -2
  65. package/dist/lib/session/parse.d.ts +63 -0
  66. package/dist/lib/session/parse.js +165 -20
  67. package/dist/lib/session/session-cache.d.ts +9 -6
  68. package/dist/lib/session/session-cache.js +23 -6
  69. package/dist/lib/shims.js +12 -0
  70. package/dist/lib/startup/command-registry.d.ts +15 -1
  71. package/dist/lib/startup/command-registry.js +49 -0
  72. package/dist/lib/usage-refresh.js +3 -2
  73. package/dist/lib/usage.d.ts +12 -10
  74. package/dist/lib/usage.js +81 -154
  75. package/package.json +4 -1
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Daemon housekeeping ticks — one-shot bodies invoked by system routines.
3
+ *
4
+ * RUSH-2353: these were ~15 hardcoded `setInterval` timers inside
5
+ * `runDaemon()` (daemon.ts) — a parallel, inferior reimplementation of the
6
+ * routines system (no declaration, no run history, no pause/disable, no
7
+ * device pin). Each function here is one migrated tick's *body*, unchanged in
8
+ * behavior, now invoked as a detached one-shot process by a system routine's
9
+ * `command:` (via the `agents __daemon-tick <name>` entrypoint in index.ts)
10
+ * instead of an in-process interval closure.
11
+ *
12
+ * Overlap protection that used to live in daemon.ts as a per-tick boolean
13
+ * flag (`watchdogInFlight`, `probingDevices`, ...) is now provided by the
14
+ * routine runner's launch claim (`withRoutineLaunchClaim` in runner.ts) — two
15
+ * fires of the same routine name never run concurrently.
16
+ *
17
+ * Output goes to `console.log`/`console.error`: each tick runs as a spawned
18
+ * child of `executeCommandJobDetached`, which redirects the child's stdout to
19
+ * the run's `stdout.log` — so this doubles as the run's log, readable via
20
+ * `agents routines runs <name>`.
21
+ */
22
+ import { getConfigValue } from './device-config.js';
23
+ /** ~every 3 min. Mirrors the old WATCHDOG_TICK_MS. */
24
+ export async function runWatchdogTick() {
25
+ if (getConfigValue('watchdog.enabled').value !== true) {
26
+ console.log('watchdog: disabled (watchdog.enabled != true) — skipping');
27
+ return;
28
+ }
29
+ const { runWatchdogPass } = await import('./watchdog/service.js');
30
+ const result = await runWatchdogPass({ nudge: true });
31
+ console.log(`watchdog: ${result.counts.total} live, ${result.counts.stalled} stalled, ${result.counts.nudged} nudged`);
32
+ }
33
+ /**
34
+ * Device probe: refresh registered devices' reachability and detect newly
35
+ * appeared tailnet nodes, dropping a sentinel per pending device so the
36
+ * menu-bar helper can surface "NEW DEVICES -> Register / Ignore". Refresh
37
+ * mode never auto-registers a newcomer. A machine without tailscale is a
38
+ * clean no-op. ~every 3 min.
39
+ */
40
+ export async function runDeviceProbeTick() {
41
+ const { runDeviceSync } = await import('./devices/sync.js');
42
+ const { reconcilePendingSentinels } = await import('./devices/pending.js');
43
+ const dev = await runDeviceSync({ soft: true, mode: 'refresh' });
44
+ if (!dev.ok) {
45
+ console.log('device probe: sync not ok — skipping sentinel reconcile');
46
+ return;
47
+ }
48
+ reconcilePendingSentinels(dev.pending);
49
+ if (dev.pending.length) {
50
+ console.log(`devices: ${dev.pending.length} new pending (${dev.pending.map((p) => p.name).join(', ')})`);
51
+ }
52
+ else {
53
+ console.log('device probe: no new pending devices');
54
+ }
55
+ }
56
+ /**
57
+ * tmux hook reconcile: retrofit the guarded `pane-died` hook onto managed
58
+ * `agents run` sessions a pre-fix binary left with the old unconditional
59
+ * hook. Non-destructive: set-hook only, never a kill or detach. ~every 5 min.
60
+ */
61
+ export async function runTmuxReconcileTick() {
62
+ const { isTmuxInstalled } = await import('./tmux/binary.js');
63
+ if (!isTmuxInstalled()) {
64
+ console.log('tmux reconcile: tmux not installed — skipping');
65
+ return;
66
+ }
67
+ const { reconcileSessionHooks } = await import('./tmux/session.js');
68
+ const r = await reconcileSessionHooks();
69
+ console.log(`tmux: retrofitted pane-died hook on ${r.reconciled} session(s)`);
70
+ }
71
+ /**
72
+ * Launch-health self-heal: probe that each agent's DEFAULT version actually
73
+ * LAUNCHES, and repair a gutted install. Never repoints the global default
74
+ * (allowDefaultSwitch: false) — a background default switch would be a
75
+ * silent logout. ~every 6h.
76
+ */
77
+ export async function runLaunchHealthTick() {
78
+ const { healBrokenDefaultLaunches } = await import('./versions.js');
79
+ const { repaired, unhealed } = await healBrokenDefaultLaunches((m) => console.log(`launch-health: ${m}`), { allowDefaultSwitch: false });
80
+ if (repaired.length)
81
+ console.log(`launch-health: repaired ${repaired.join(', ')}`);
82
+ if (unhealed.length) {
83
+ console.log(`launch-health: ${unhealed.join(', ')} won't launch and will not be auto-switched — choose a version with \`agents use <agent> <version>\` or \`agents add <agent>@latest\``);
84
+ }
85
+ if (!repaired.length && !unhealed.length)
86
+ console.log('launch-health: all default launches healthy');
87
+ }
88
+ /**
89
+ * Fleet cache warm: publish THIS host's row for the caches `agents fleet
90
+ * status` / `agents devices list` read (PUBLISH-OWN / READ-UNION, RUSH-2061).
91
+ * ~every 3 min.
92
+ */
93
+ export async function runFleetCacheWarmTick() {
94
+ const { machineId } = await import('./machine-id.js');
95
+ const self = machineId();
96
+ const { probeLocalFleetAuth, writeFleetAuthRows } = await import('./auth-health.js');
97
+ const { getCliVersion } = await import('./version.js');
98
+ const authRows = await probeLocalFleetAuth({ cliVersion: getCliVersion() });
99
+ writeFleetAuthRows(self, authRows);
100
+ const { publishLocalFleetStatus } = await import('./fleet-status.js');
101
+ const row = await publishLocalFleetStatus(self);
102
+ console.log(`fleet cache warm: ${authRows.length} auth row(s), ${row.agents.running} running agent(s) on ${self}`);
103
+ }
104
+ /**
105
+ * Session-status cache warm (RUSH-2062): publish THIS host's local active
106
+ * sessions so menubar / Factory / watchdog / CLI share one warm snapshot.
107
+ * Publish-own only (no cross-host SSH). ~every 3 min.
108
+ */
109
+ export async function runSessionCacheWarmTick() {
110
+ const { publishLocalActiveSessions } = await import('./session/session-cache.js');
111
+ const r = await publishLocalActiveSessions();
112
+ console.log(`session cache warm: ${r.sessions.length} local session(s)`);
113
+ }
114
+ /**
115
+ * Usage refresh: keep the usage cache the `agents run` router reads
116
+ * (RUSH-2061, readOnly hot path) fresh, WITHOUT the hot path ever fetching.
117
+ * This host is the sole writer for its own local accounts. ~every 60s
118
+ * (USAGE_REFRESH_TICK_MS in usage-refresh.ts — keep in sync).
119
+ */
120
+ export async function runUsageRefreshTick() {
121
+ const { runUsageRefresh, buildLocalUsageAccounts } = await import('./usage-refresh.js');
122
+ const { writeClaudeUsageCache } = await import('./usage.js');
123
+ const { usageRateLimitedUntil } = await import('./usage-backoff.js');
124
+ const r = await runUsageRefresh({
125
+ listAccounts: buildLocalUsageAccounts,
126
+ writeUsageCache: writeClaudeUsageCache,
127
+ backoffUntil: usageRateLimitedUntil,
128
+ });
129
+ console.log(`usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped`);
130
+ }
131
+ /**
132
+ * Auto-dispatch: for any managed project that has opted in (autoDispatch:true
133
+ * + maxAgents>0 in ~/.agents/factory/projects.json), pick up Linear tickets
134
+ * delegated to an agent and still in Todo, and dispatch each through
135
+ * agents-cli's own cloud-provider layer. OFF unless a project opts in; no
136
+ * opted-in project or no LINEAR_API_KEY is a clean no-op. ~every 3 min.
137
+ *
138
+ * Migrated to a routine (RUSH-2353) so it inherits the `devices:` allowlist —
139
+ * pin with `agents routines devices auto-dispatch --set <one>` to fix the
140
+ * shared-input double-fire problem this job had hardcoded (every daemon on
141
+ * the fleet polled the same Linear queue with no coordination).
142
+ */
143
+ export async function runAutoDispatchTick() {
144
+ const { readAutoDispatchProjects, isEligible, autoDispatchTick } = await import('./auto-dispatch.js');
145
+ const projects = readAutoDispatchProjects();
146
+ if (!projects.some(isEligible)) {
147
+ console.log('auto-dispatch: no opted-in project — skipping');
148
+ return;
149
+ }
150
+ const { createLinearGateway } = await import('./auto-dispatch-linear.js');
151
+ const linear = createLinearGateway();
152
+ if (!linear) {
153
+ console.log('auto-dispatch: no LINEAR_API_KEY configured — skipping');
154
+ return;
155
+ }
156
+ const { createProviderDispatcher } = await import('./auto-dispatch-provider.js');
157
+ const dispatcher = createProviderDispatcher();
158
+ const dispatched = await autoDispatchTick({
159
+ projects,
160
+ linear,
161
+ dispatcher,
162
+ log: (lvl, m) => (lvl === 'ERROR' ? console.error(m) : console.log(m)),
163
+ });
164
+ if (dispatched.length) {
165
+ console.log(`auto-dispatch: started ${dispatched.length} delegated ticket(s): ${dispatched.map((d) => d.identifier).join(', ')}`);
166
+ }
167
+ else {
168
+ console.log('auto-dispatch: no delegated tickets to dispatch');
169
+ }
170
+ }
171
+ /** Registry: routine-facing name -> tick body. Keys match the shipped routine YAML names. */
172
+ export const DAEMON_TICKS = {
173
+ watchdog: runWatchdogTick,
174
+ 'device-probe': runDeviceProbeTick,
175
+ 'tmux-reconcile': runTmuxReconcileTick,
176
+ 'launch-health': runLaunchHealthTick,
177
+ 'fleet-cache-warm': runFleetCacheWarmTick,
178
+ 'session-cache-warm': runSessionCacheWarmTick,
179
+ 'usage-refresh': runUsageRefreshTick,
180
+ 'auto-dispatch': runAutoDispatchTick,
181
+ };
182
+ export const DAEMON_TICK_ROUTINE_NAMES = Object.freeze(Object.keys(DAEMON_TICKS));
183
+ /** Run one named tick, or throw for an unknown name (fails the routine run loud). */
184
+ export async function runDaemonTick(name) {
185
+ const fn = DAEMON_TICKS[name];
186
+ if (!fn) {
187
+ throw new Error(`Unknown daemon tick '${name}'. Known: ${Object.keys(DAEMON_TICKS).join(', ')}`);
188
+ }
189
+ await fn();
190
+ }
@@ -58,25 +58,45 @@ export declare function isDaemonRunning(): boolean;
58
58
  * writeDaemonPid() unconditionally, clobber a live daemon's recorded PID, and
59
59
  * run a second JobScheduler concurrently, so every cron routine fires twice.
60
60
  *
61
- * Returns true and records our PID when no other live daemon owns the pid file;
62
- * returns false when a live daemon already holds it (the caller must exit
63
- * without touching any further state). The read-decide-write is serialized
64
- * behind the same O_EXCL start lock startDaemon() uses, so two _run processes
65
- * can't both claim in the window between the liveness check and the write.
61
+ * LAST-WINS takeover (SING-11, RUSH-2352): when a live daemon already owns the
62
+ * pid file, this does NOT defer to it it evicts the incumbent and becomes the
63
+ * survivor, so a second install can never leave two daemons running. Returns true
64
+ * and records our PID once the incumbent is provably dead (its resources
65
+ * released). Returns false ONLY when another `__daemon-run` currently holds the
66
+ * O_EXCL start lock — i.e. a concurrent claimer is mid-takeover and will be the
67
+ * singleton — in which case the caller must exit without touching further state.
68
+ * The read-evict-write is serialized behind the same start lock startDaemon()
69
+ * uses, so two `_run` processes can't both claim in the window between the
70
+ * liveness check and the write.
66
71
  */
67
72
  export declare function claimDaemonInstance(): boolean;
68
73
  /**
69
- * Reap stray duplicate daemon processes — a `__daemon-run` of THIS install that
70
- * isn't this process and isn't the pid-file owner. Mirrors the browser orphan
71
- * reaper (below): a predecessor that was SIGKILLed/OOM-ed without cleaning up,
72
- * or a duplicate that lost the pid-file write race, would otherwise keep a
73
- * second scheduler alive and double-fire jobs even after claimDaemonInstance()
74
- * hands the pid file to the survivor.
75
- *
76
- * Scoped to our own launch entry (process.argv[1]) so it only ever targets
77
- * daemons of the same installation a daemon from a different install / home
78
- * (e.g. a side-by-side dev build, or a test fixture) is a legitimately separate
79
- * instance and is left untouched. POSIX-only (uses `ps`); a no-op on Windows.
74
+ * Record this daemon in the device's instance registry — a marker file named by
75
+ * pid under `<daemonDir>/instances/`. The registry, not a process scan, is how
76
+ * the reaper enumerates the device singleton: because the dir lives INSIDE the
77
+ * daemon dir (`AGENTS_DAEMON_DIR` ?? `<HOME>/.agents/.cache/helpers/daemon`), every
78
+ * daemon of one device however it was launched — registers in the same place,
79
+ * while a genuinely separate install/home or a test fixture registers under its
80
+ * own daemon dir and is invisible here. This is what fixes the two-entry pile-up:
81
+ * the compiled `dist/bin/agents` binary and the `node <shim>` JS entry have
82
+ * different `process.argv[1]`, so the old launch-entry-scoped `ps` match never
83
+ * reaped across them and duplicates accumulated (78 observed on one box), every
84
+ * routine double-firing. Best-effort the reaper self-heals a missing/stale
85
+ * marker, and reading another process's ENV to key on the daemon dir directly is
86
+ * not portable (hardened macOS hides it from `ps`), so identity rides the shared
87
+ * on-disk registry instead. No-op on Windows (POSIX-only reaper).
88
+ */
89
+ export declare function registerDaemonInstance(pid?: number): void;
90
+ /** Remove this daemon's registry marker on graceful shutdown. */
91
+ export declare function unregisterDaemonInstance(pid?: number): void;
92
+ /**
93
+ * Reap stray duplicate daemons of THIS device — every registrant in the instance
94
+ * registry that is a live `agents __daemon-run` and is neither this process nor
95
+ * the current pid-file owner. A predecessor SIGKILLed/OOM-ed without cleanup, or a
96
+ * duplicate that lost the pid-file write race, would otherwise keep a second
97
+ * scheduler alive and double-fire jobs even after claimDaemonInstance() hands the
98
+ * pid file to the survivor. Also garbage-collects markers whose pid is dead or was
99
+ * reused by an unrelated process. No-op on Windows (POSIX-only).
80
100
  */
81
101
  export declare function reapStrayDaemons(keepPid?: number): {
82
102
  reaped: number;
@@ -236,8 +256,38 @@ export declare function startDetached(opts?: StartDetachedOptions): {
236
256
  pid: number | null;
237
257
  method: string;
238
258
  };
239
- /** Stop the daemon, unloading it from launchd/systemd if applicable. */
240
- export declare function stopDaemon(): boolean;
259
+ /**
260
+ * Structured outcome of {@link stopDaemon} (SING-12, RUSH-2355). `stopDaemon`
261
+ * asserts its postcondition instead of assuming it: `ok` is true only when every
262
+ * resource the daemon held is provably released. `surviving` names anything that
263
+ * did not release (a still-live daemon, or a stale socket that could not be
264
+ * cleared) and is what drives a non-zero exit; `detachedChildren` are the
265
+ * in-flight routine children that survive deliberately (SING-11a) and are
266
+ * reported, never killed.
267
+ */
268
+ export interface DaemonStopResult {
269
+ ok: boolean;
270
+ stoppedPid: number | null;
271
+ escalated: boolean;
272
+ released: string[];
273
+ surviving: string[];
274
+ detachedChildren: number[];
275
+ }
276
+ /**
277
+ * Stop the daemon and ASSERT its postcondition (SING-12, RUSH-2355), unloading it
278
+ * from launchd/systemd if applicable.
279
+ *
280
+ * The SIGTERM → grace → killTree sequence is unchanged; what it adds is
281
+ * verification. After the daemon is gone it checks that the secrets broker socket
282
+ * and browser IPC binding actually released — a stale socket present on disk but
283
+ * with no live owner is the orphan that keeps clients holding unlocked bundles
284
+ * hanging (`daemon.ts` broker-hosting; the two-brokers-on-one-socket bug) — and
285
+ * that no `__daemon-run` for THIS state dir survives. A killTree escalation exits
286
+ * without running the daemon's graceful handleShutdown, so those sockets can be
287
+ * left stale; this reclaims each (the owner is provably dead) and reports it. It
288
+ * never reports success on an unverified stop.
289
+ */
290
+ export declare function stopDaemon(): DaemonStopResult;
241
291
  /** Get current daemon status including running state, PID, and enabled job count. */
242
292
  export declare function getDaemonStatus(): {
243
293
  state: 'running' | 'wedged' | 'stopped';