@phnx-labs/agents-cli 1.20.93 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/beta.js +3 -5
  5. package/dist/commands/doctor.js +23 -0
  6. package/dist/commands/exec.js +13 -0
  7. package/dist/commands/mailboxes.js +39 -1
  8. package/dist/commands/message.js +12 -1
  9. package/dist/commands/packages.js +14 -5
  10. package/dist/commands/projects.d.ts +28 -0
  11. package/dist/commands/projects.js +173 -47
  12. package/dist/commands/routines.js +27 -1
  13. package/dist/commands/rules.js +6 -0
  14. package/dist/commands/sessions.d.ts +17 -0
  15. package/dist/commands/sessions.js +16 -8
  16. package/dist/commands/setup-browser.js +5 -1
  17. package/dist/commands/setup-fleet.js +5 -0
  18. package/dist/commands/setup-preferences.d.ts +53 -0
  19. package/dist/commands/setup-preferences.js +142 -0
  20. package/dist/commands/setup.js +8 -9
  21. package/dist/commands/ssh.js +220 -10
  22. package/dist/commands/sync.d.ts +2 -2
  23. package/dist/commands/sync.js +4 -9
  24. package/dist/commands/watchdog.js +26 -3
  25. package/dist/index.js +22 -6
  26. package/dist/lib/activity.d.ts +7 -0
  27. package/dist/lib/activity.js +19 -2
  28. package/dist/lib/auto-dispatch.d.ts +6 -1
  29. package/dist/lib/auto-dispatch.js +7 -2
  30. package/dist/lib/beta.d.ts +1 -1
  31. package/dist/lib/beta.js +1 -1
  32. package/dist/lib/daemon.d.ts +13 -0
  33. package/dist/lib/daemon.js +80 -60
  34. package/dist/lib/device-config.d.ts +82 -0
  35. package/dist/lib/device-config.js +296 -0
  36. package/dist/lib/event-provenance.d.ts +19 -0
  37. package/dist/lib/event-provenance.js +48 -0
  38. package/dist/lib/events.d.ts +2 -2
  39. package/dist/lib/events.js +4 -53
  40. package/dist/lib/feed.d.ts +1 -1
  41. package/dist/lib/feed.js +2 -0
  42. package/dist/lib/git.d.ts +13 -2
  43. package/dist/lib/git.js +38 -6
  44. package/dist/lib/linear-project-counts.d.ts +72 -5
  45. package/dist/lib/linear-project-counts.js +90 -6
  46. package/dist/lib/linear-projects.d.ts +12 -0
  47. package/dist/lib/linear-projects.js +30 -0
  48. package/dist/lib/mailbox-gc.js +30 -7
  49. package/dist/lib/mailbox.d.ts +14 -1
  50. package/dist/lib/mailbox.js +35 -3
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  54. package/dist/lib/menubar/install-menubar.js +35 -44
  55. package/dist/lib/migrate.js +1 -1
  56. package/dist/lib/models.js +21 -11
  57. package/dist/lib/overdue.d.ts +1 -1
  58. package/dist/lib/overdue.js +54 -19
  59. package/dist/lib/project-import.d.ts +96 -0
  60. package/dist/lib/project-import.js +171 -0
  61. package/dist/lib/remote-agents-json.d.ts +14 -1
  62. package/dist/lib/remote-agents-json.js +21 -3
  63. package/dist/lib/routines.d.ts +53 -0
  64. package/dist/lib/routines.js +105 -4
  65. package/dist/lib/rules/run-sync.d.ts +18 -0
  66. package/dist/lib/rules/run-sync.js +92 -0
  67. package/dist/lib/scheduler.js +12 -2
  68. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  69. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  70. package/dist/lib/secrets/index.d.ts +4 -5
  71. package/dist/lib/secrets/index.js +4 -5
  72. package/dist/lib/session/active.d.ts +16 -2
  73. package/dist/lib/session/active.js +8 -4
  74. package/dist/lib/session/bundle.js +5 -1
  75. package/dist/lib/session/remote-list.d.ts +1 -15
  76. package/dist/lib/session/remote-list.js +22 -111
  77. package/dist/lib/session/sync/config.d.ts +13 -5
  78. package/dist/lib/session/sync/config.js +21 -13
  79. package/dist/lib/startup/command-registry.d.ts +0 -2
  80. package/dist/lib/startup/command-registry.js +1 -5
  81. package/dist/lib/state.d.ts +7 -2
  82. package/dist/lib/state.js +85 -10
  83. package/dist/lib/sync-umbrella.d.ts +11 -23
  84. package/dist/lib/sync-umbrella.js +14 -45
  85. package/dist/lib/teams/agents.d.ts +35 -3
  86. package/dist/lib/teams/agents.js +52 -8
  87. package/dist/lib/teams/scheduler.d.ts +29 -2
  88. package/dist/lib/teams/scheduler.js +60 -16
  89. package/dist/lib/types.d.ts +17 -1
  90. package/package.json +1 -2
  91. package/dist/commands/drive.d.ts +0 -10
  92. package/dist/commands/drive.js +0 -183
  93. package/dist/commands/hq.d.ts +0 -2
  94. package/dist/commands/hq.js +0 -58
  95. package/dist/commands/sessions-sync.d.ts +0 -17
  96. package/dist/commands/sessions-sync.js +0 -135
  97. package/dist/commands/sync-provision.d.ts +0 -23
  98. package/dist/commands/sync-provision.js +0 -107
  99. package/dist/lib/drive-sync.d.ts +0 -45
  100. package/dist/lib/drive-sync.js +0 -238
  101. package/dist/lib/hq/floor.d.ts +0 -87
  102. package/dist/lib/hq/floor.js +0 -243
  103. package/dist/lib/session/sync/crdt.d.ts +0 -44
  104. package/dist/lib/session/sync/crdt.js +0 -119
  105. package/dist/lib/session/sync/manifest.d.ts +0 -62
  106. package/dist/lib/session/sync/manifest.js +0 -100
  107. package/dist/lib/session/sync/provision.d.ts +0 -49
  108. package/dist/lib/session/sync/provision.js +0 -91
  109. package/dist/lib/session/sync/r2.d.ts +0 -32
  110. package/dist/lib/session/sync/r2.js +0 -121
  111. package/dist/lib/session/sync/sync.d.ts +0 -106
  112. package/dist/lib/session/sync/sync.js +0 -374
@@ -15,6 +15,15 @@ import { loadDevices, isControlDevice, isDialableDevice } from './devices/regist
15
15
  import { remoteShellFor, buildWindowsAgentsCommand } from './hosts/remote-cmd.js';
16
16
  import { machineId, normalizeHost } from './machine-id.js';
17
17
  const REMOTE_TIMEOUT_MS = 12_000;
18
+ export function normalizeRemoteAgentsJsonParse(parsed) {
19
+ return Array.isArray(parsed) ? { items: parsed, valid: true } : parsed;
20
+ }
21
+ export function parseRemoteAgentsJsonPayload(stdout, machine, parse) {
22
+ const parsed = normalizeRemoteAgentsJsonParse(parse(stdout, machine));
23
+ return parsed.valid
24
+ ? { items: parsed.items, parseFailed: false }
25
+ : { items: [], parseFailed: true };
26
+ }
18
27
  /** Build the command one peer runs, with a guard that prevents recursive fan-out. */
19
28
  export function remoteAgentsJsonCommand(args, noFanoutEnv, os) {
20
29
  if (remoteShellFor(os) === 'powershell') {
@@ -59,7 +68,7 @@ export async function gatherRemoteAgentsJson(options) {
59
68
  devices = await loadDevices();
60
69
  }
61
70
  catch {
62
- return { items: [], deviceCount: 0, skipped: [] };
71
+ return { items: [], deviceCount: 0, skipped: [], parseFailed: [], discoveryFailed: true };
63
72
  }
64
73
  for (const device of Object.values(devices)) {
65
74
  // Live SSH-probe verdict first, cached tailscale snapshot only as a
@@ -90,6 +99,7 @@ export async function gatherRemoteAgentsJson(options) {
90
99
  }
91
100
  }
92
101
  const skipped = [];
102
+ const parseFailed = [];
93
103
  const results = await Promise.all(targets.map(async (target) => {
94
104
  const command = remoteAgentsJsonCommand(options.args, options.noFanoutEnv, target.os);
95
105
  const result = await sshCapture(target.target, command);
@@ -100,7 +110,15 @@ export async function gatherRemoteAgentsJson(options) {
100
110
  }
101
111
  return [];
102
112
  }
103
- return options.parse(result.stdout, target.machine);
113
+ const parsed = parseRemoteAgentsJsonPayload(result.stdout, target.machine, options.parse);
114
+ if (parsed.parseFailed) {
115
+ parseFailed.push(target.name);
116
+ if (!options.quiet) {
117
+ process.stderr.write(chalk.gray(` ${target.name}: unreachable or no agents CLI — skipped\n`));
118
+ }
119
+ return [];
120
+ }
121
+ return parsed.items;
104
122
  }));
105
- return { items: results.flat(), deviceCount: targets.length, skipped };
123
+ return { items: results.flat(), deviceCount: targets.length, skipped, parseFailed, discoveryFailed: false };
106
124
  }
@@ -293,6 +293,47 @@ export declare function finalizeRunMeta(meta: RunMeta, status: RunMeta['status']
293
293
  * catchup/overdue, manual run) gates on this.
294
294
  */
295
295
  export declare function jobRunsOnThisDevice(config: Pick<JobConfig, 'devices'>): boolean;
296
+ /**
297
+ * The ONE device that owns a routine — the single daemon allowed to fire it.
298
+ *
299
+ * `devices` is an allowlist, and every listed device used to fire
300
+ * independently, so a routine pinned to two boxes ran **twice** per schedule:
301
+ * two full agent sessions doing identical work, burning double the quota. On
302
+ * this fleet seven routines were in that state, e.g. `security-sweep` running
303
+ * at 15:30:02 on one box and 15:30:03 on the other, both completing.
304
+ *
305
+ * Ownership is a pure function of the config — the first entry in normalized
306
+ * sort order — so every daemon independently reaches the same answer with no
307
+ * lease, no cross-device coordination, and no split brain when the fleet
308
+ * partitions. A multi-entry pin is a misconfiguration
309
+ * ({@link hasAmbiguousDevicePin}); this keeps such a routine running exactly
310
+ * once instead of silently dropping it, while `validateJob` refuses to create
311
+ * a new one and `agents doctor` surfaces the existing ones.
312
+ *
313
+ * Returns null when the routine is unrestricted (empty or omitted `devices`).
314
+ */
315
+ export declare function routineOwnerDevice(config: Pick<JobConfig, 'devices'>): string | null;
316
+ /**
317
+ * Does this routine name more than one distinct device? Such a pin used to mean
318
+ * "fire on each of them"; it now means "fire only on the first", which is
319
+ * almost certainly not what the author intended either way — so it is reported
320
+ * as a misconfiguration rather than silently reinterpreted.
321
+ */
322
+ export declare function hasAmbiguousDevicePin(config: Pick<JobConfig, 'devices'>): boolean;
323
+ /** One routine whose `devices` names more than one machine, with its resolved owner. */
324
+ export interface AmbiguousDevicePin {
325
+ name: string;
326
+ devices: string[];
327
+ /** The device that now fires it — the rest are inert. */
328
+ owner: string;
329
+ }
330
+ /**
331
+ * Every routine carrying a multi-device pin. Surfaced by `agents doctor` and
332
+ * `agents routines list` so an existing misconfiguration is visible rather than
333
+ * silently reinterpreted: before ownership became singular each of these fired
334
+ * once per listed device, doubling the work and the agent spend.
335
+ */
336
+ export declare function findAmbiguousDevicePins(cwd?: string): AmbiguousDevicePin[];
296
337
  /**
297
338
  * Resolve the effective host strategy for a job.
298
339
  * Bare `host:` without an explicit strategy implies `host` (back-compat with
@@ -487,6 +528,18 @@ export declare function discoverJobsFromRepo(repoPath: string): Array<{
487
528
  export declare function jobExists(name: string): boolean;
488
529
  /** Get the filesystem path of a job's YAML config file, or null if not found. */
489
530
  export declare function getJobPath(name: string): string | null;
531
+ /**
532
+ * Resolve a routine's YAML across EVERY layer `listJobs`/`readJob` read — user
533
+ * then system — not just the user dir.
534
+ *
535
+ * `getJobPath` is user-layer only because its callers write there. Read paths
536
+ * that ask "when did this routine come to exist" need the system layer too:
537
+ * a built-in shipped in the system repo has no user-layer file and no
538
+ * `createdAt`, so a user-layer-only lookup returns null, the overdue floor is
539
+ * skipped, and the routine reads as instantly overdue on first daemon start —
540
+ * exactly the case the floor exists to prevent.
541
+ */
542
+ export declare function resolveJobFilePath(name: string): string | null;
490
543
  /**
491
544
  * Parse an "at" time string into a one-shot cron expression.
492
545
  * Supports formats like:
@@ -81,10 +81,67 @@ export function finalizeRunMeta(meta, status, exitCode, opts) {
81
81
  * catchup/overdue, manual run) gates on this.
82
82
  */
83
83
  export function jobRunsOnThisDevice(config) {
84
- if (!config.devices || config.devices.length === 0)
84
+ const owner = routineOwnerDevice(config);
85
+ // Unrestricted: no pin means fleet-wide by design (`watchdog`, `check-updates`).
86
+ if (owner === null)
85
87
  return true;
86
- const self = machineId();
87
- return config.devices.some((d) => normalizeHost(d) === self);
88
+ return owner === machineId();
89
+ }
90
+ /**
91
+ * The ONE device that owns a routine — the single daemon allowed to fire it.
92
+ *
93
+ * `devices` is an allowlist, and every listed device used to fire
94
+ * independently, so a routine pinned to two boxes ran **twice** per schedule:
95
+ * two full agent sessions doing identical work, burning double the quota. On
96
+ * this fleet seven routines were in that state, e.g. `security-sweep` running
97
+ * at 15:30:02 on one box and 15:30:03 on the other, both completing.
98
+ *
99
+ * Ownership is a pure function of the config — the first entry in normalized
100
+ * sort order — so every daemon independently reaches the same answer with no
101
+ * lease, no cross-device coordination, and no split brain when the fleet
102
+ * partitions. A multi-entry pin is a misconfiguration
103
+ * ({@link hasAmbiguousDevicePin}); this keeps such a routine running exactly
104
+ * once instead of silently dropping it, while `validateJob` refuses to create
105
+ * a new one and `agents doctor` surfaces the existing ones.
106
+ *
107
+ * Returns null when the routine is unrestricted (empty or omitted `devices`).
108
+ */
109
+ export function routineOwnerDevice(config) {
110
+ // A non-array `devices` is a separate validation error; don't throw here and
111
+ // don't double-report it.
112
+ if (!Array.isArray(config.devices))
113
+ return null;
114
+ const devices = config.devices.map((d) => normalizeHost(String(d))).filter(Boolean);
115
+ if (devices.length === 0)
116
+ return null;
117
+ return [...devices].sort()[0];
118
+ }
119
+ /**
120
+ * Does this routine name more than one distinct device? Such a pin used to mean
121
+ * "fire on each of them"; it now means "fire only on the first", which is
122
+ * almost certainly not what the author intended either way — so it is reported
123
+ * as a misconfiguration rather than silently reinterpreted.
124
+ */
125
+ export function hasAmbiguousDevicePin(config) {
126
+ if (!Array.isArray(config.devices))
127
+ return false;
128
+ const devices = new Set(config.devices.map((d) => normalizeHost(String(d))).filter(Boolean));
129
+ return devices.size > 1;
130
+ }
131
+ /**
132
+ * Every routine carrying a multi-device pin. Surfaced by `agents doctor` and
133
+ * `agents routines list` so an existing misconfiguration is visible rather than
134
+ * silently reinterpreted: before ownership became singular each of these fired
135
+ * once per listed device, doubling the work and the agent spend.
136
+ */
137
+ export function findAmbiguousDevicePins(cwd) {
138
+ return listJobs(cwd)
139
+ .filter((job) => job.enabled && hasAmbiguousDevicePin(job))
140
+ .map((job) => ({
141
+ name: job.name,
142
+ devices: (job.devices ?? []).map((d) => normalizeHost(d)),
143
+ owner: routineOwnerDevice(job) ?? '',
144
+ }));
88
145
  }
89
146
  /**
90
147
  * Resolve the effective host strategy for a job.
@@ -130,7 +187,10 @@ export function checkJobDeviceEligibility(config) {
130
187
  return null;
131
188
  const allowed = (config.devices ?? []).map((d) => normalizeHost(d));
132
189
  const allowedLabel = allowed.join(', ');
133
- const firstHost = allowed[0] ?? 'HOST';
190
+ // The owner is the lowest normalized name, not the first entry as written —
191
+ // suggesting allowed[0] on an unsorted list points at a device that will
192
+ // refuse the run for exactly the same reason.
193
+ const firstHost = routineOwnerDevice(config) ?? allowed[0] ?? 'HOST';
134
194
  const message = `Job '${config.name}' can only run on: ${allowedLabel}`;
135
195
  const suggestion = `agents routines run ${config.name} --host ${firstHost}`;
136
196
  return { message, suggestion, allowedLabel, firstHost };
@@ -251,6 +311,17 @@ function readJobFile(filePath) {
251
311
  // treated as unavailable/inert rather than unrestricted.
252
312
  if (Object.prototype.hasOwnProperty.call(parsed, 'device'))
253
313
  return null;
314
+ // Fail closed on a malformed `devices` too. Ownership treats a non-array as
315
+ // "no pin", and the daemon's load path never calls validateJob — so a YAML
316
+ // typo (`devices: yosemite-s0` instead of a list) would silently promote the
317
+ // routine to fleet-wide and fire it on EVERY box. Inert-and-loud beats
318
+ // unrestricted-and-silent.
319
+ if (Object.prototype.hasOwnProperty.call(parsed, 'devices')
320
+ && parsed.devices !== undefined
321
+ && parsed.devices !== null
322
+ && !Array.isArray(parsed.devices)) {
323
+ return null;
324
+ }
254
325
  return {
255
326
  ...JOB_DEFAULTS,
256
327
  ...parsed,
@@ -523,6 +594,14 @@ export function validateJob(config) {
523
594
  }
524
595
  }
525
596
  }
597
+ // A routine belongs to exactly one device. Listing several used to fire it
598
+ // once per device — duplicate work, duplicate spend — and making them elect
599
+ // one owner at runtime would need cross-device coordination nobody wants.
600
+ if (hasAmbiguousDevicePin(config)) {
601
+ errors.push(`devices lists ${new Set(config.devices.map((d) => normalizeHost(String(d)))).size} devices; a routine runs on exactly one. ` +
602
+ 'Pin the single device that should own it, e.g. devices: [yosemite-s0]. ' +
603
+ 'Omit devices entirely for a routine that genuinely belongs on every machine.');
604
+ }
526
605
  if (config.catchup !== undefined && typeof config.catchup !== 'boolean') {
527
606
  errors.push('catchup must be a boolean (false to skip running a missed fire late)');
528
607
  }
@@ -946,6 +1025,28 @@ export function getJobPath(name) {
946
1025
  }
947
1026
  return null;
948
1027
  }
1028
+ /**
1029
+ * Resolve a routine's YAML across EVERY layer `listJobs`/`readJob` read — user
1030
+ * then system — not just the user dir.
1031
+ *
1032
+ * `getJobPath` is user-layer only because its callers write there. Read paths
1033
+ * that ask "when did this routine come to exist" need the system layer too:
1034
+ * a built-in shipped in the system repo has no user-layer file and no
1035
+ * `createdAt`, so a user-layer-only lookup returns null, the overdue floor is
1036
+ * skipped, and the routine reads as instantly overdue on first daemon start —
1037
+ * exactly the case the floor exists to prevent.
1038
+ */
1039
+ export function resolveJobFilePath(name) {
1040
+ const userPath = getJobPath(name);
1041
+ if (userPath)
1042
+ return userPath;
1043
+ for (const ext of ['.yml', '.yaml']) {
1044
+ const filePath = safeJoin(getSystemRoutinesDir(), name + ext);
1045
+ if (fs.existsSync(filePath))
1046
+ return filePath;
1047
+ }
1048
+ return null;
1049
+ }
949
1050
  /**
950
1051
  * Parse an "at" time string into a one-shot cron expression.
951
1052
  * Supports formats like:
@@ -0,0 +1,18 @@
1
+ import type { AgentId } from '../types.js';
2
+ /**
3
+ * Re-apply the active rules preset for (agent, version) into its version
4
+ * home when the composed source set has drifted since the last time this ran.
5
+ * Returns true when the version-home rules file was (re)written.
6
+ *
7
+ * No-cwd fingerprint on purpose: the version-home rules file never includes
8
+ * the project layer (see `staleness/writers/rules.ts` — project rules are
9
+ * resolved separately, at launch, into the workspace `AGENTS.md` by
10
+ * `compileRulesForProject`). Fingerprinting with a cwd would pull the project
11
+ * layer's subrules into the comparison and trigger a pointless version-home
12
+ * rewrite every time a project's own `.agents/rules/` changes.
13
+ *
14
+ * Silent on any failure (no rules.yaml, unknown preset, unsupported agent) —
15
+ * mirrors `syncResourcesToVersion`'s own catch-and-skip for rules: a bad
16
+ * preset must never block a launch, and this now also runs on the hot path.
17
+ */
18
+ export declare function applyActiveRulesPresetAtRun(agent: AgentId, version: string, versionHome: string): boolean;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Rules preset auto-apply at `agents run` launch time.
3
+ *
4
+ * `agents rules switch <agent>@<version> --preset <name>` persists the active
5
+ * preset (`state.ts:setActiveRulesPreset`) and immediately recompiles it into
6
+ * the version home via `syncResourcesToVersion` → the rules writer
7
+ * (`staleness/writers/rules.ts`). Same for `agents add`/`use`. But nothing
8
+ * re-applies the preset on a LATER `agents run` — if `setActiveRulesPreset`
9
+ * is ever called without a follow-up sync (or a subrule file changes after
10
+ * the last sync), the harness launches against a stale rules file until the
11
+ * next explicit `agents rules switch` / `agents sync`.
12
+ *
13
+ * This closes that gap the same way `runLaunchSync` (project-launch.ts)
14
+ * recompiles PROJECT rules on every launch: idempotent, and skip-fast when
15
+ * nothing changed. The skip-fast reuses the staleness checkers' mtime+size
16
+ * fingerprint comparison (`staleness/checkers/rules.ts`,
17
+ * `staleness/fingerprint.ts`) against a small per-(agent,version) sentinel —
18
+ * the same "hash the cheap inputs, compare, skip the write on a match" shape
19
+ * `installScope` (project-launch.ts) uses for plugin marketplaces. The common
20
+ * case (no preset or subrule change since the last run) costs one JSON read
21
+ * plus a handful of `stat()` calls — no recompose, no write.
22
+ *
23
+ * Version-level scope only — the active preset is keyed by (agent, version),
24
+ * matching `getActiveRulesPreset`. Per-model preset scoping is a follow-up.
25
+ */
26
+ import * as fs from 'fs';
27
+ import * as path from 'path';
28
+ import { AGENTS } from '../agents.js';
29
+ import { getActiveRulesPreset, getCacheDir } from '../state.js';
30
+ import { getWriter } from '../staleness/registry.js';
31
+ import { buildRules, isRulesStale } from '../staleness/checkers/rules.js';
32
+ /** ~/.agents/.cache/rules-run-sync/ — regenerable; a lost sentinel just costs one extra compose+write. */
33
+ function sentinelPath(agent, version) {
34
+ const key = `${agent}@${version}`.replace(/[^a-zA-Z0-9@._-]/g, '_');
35
+ return path.join(getCacheDir(), 'rules-run-sync', `${key}.json`);
36
+ }
37
+ function loadSentinel(agent, version) {
38
+ try {
39
+ return JSON.parse(fs.readFileSync(sentinelPath(agent, version), 'utf-8'));
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ function saveSentinel(agent, version, sentinel) {
46
+ const p = sentinelPath(agent, version);
47
+ try {
48
+ fs.mkdirSync(path.dirname(p), { recursive: true });
49
+ fs.writeFileSync(p, JSON.stringify(sentinel));
50
+ }
51
+ catch {
52
+ // Best-effort — a failed write just means the next run redoes the compose+write.
53
+ }
54
+ }
55
+ /**
56
+ * Re-apply the active rules preset for (agent, version) into its version
57
+ * home when the composed source set has drifted since the last time this ran.
58
+ * Returns true when the version-home rules file was (re)written.
59
+ *
60
+ * No-cwd fingerprint on purpose: the version-home rules file never includes
61
+ * the project layer (see `staleness/writers/rules.ts` — project rules are
62
+ * resolved separately, at launch, into the workspace `AGENTS.md` by
63
+ * `compileRulesForProject`). Fingerprinting with a cwd would pull the project
64
+ * layer's subrules into the comparison and trigger a pointless version-home
65
+ * rewrite every time a project's own `.agents/rules/` changes.
66
+ *
67
+ * Silent on any failure (no rules.yaml, unknown preset, unsupported agent) —
68
+ * mirrors `syncResourcesToVersion`'s own catch-and-skip for rules: a bad
69
+ * preset must never block a launch, and this now also runs on the hot path.
70
+ */
71
+ export function applyActiveRulesPresetAtRun(agent, version, versionHome) {
72
+ const cap = AGENTS[agent].capabilities.rules;
73
+ if (cap === false)
74
+ return false;
75
+ const rulesWriter = getWriter('rules', agent);
76
+ if (!rulesWriter)
77
+ return false;
78
+ const preset = getActiveRulesPreset(agent, version);
79
+ const current = buildRules(agent, version, '');
80
+ const stored = loadSentinel(agent, version);
81
+ if (stored && stored.preset === preset && !isRulesStale(stored.entry, agent, version, '')) {
82
+ return false; // skip-fast: preset unchanged AND composed source set unchanged
83
+ }
84
+ try {
85
+ rulesWriter.write({ version, versionHome, selection: { preset }, cwd: '' });
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ saveSentinel(agent, version, { preset, entry: current });
91
+ return true;
92
+ }
@@ -6,7 +6,7 @@
6
6
  * on startup and reloads them on SIGHUP.
7
7
  */
8
8
  import { Cron } from 'croner';
9
- import { listJobs, deleteJob, isPastEndAt, isPastOneShotRoutine, isOneShotRoutine, setJobEnabled, shouldPurgeCompletedOneShotRoutine, jobRunsOnThisDevice, } from './routines.js';
9
+ import { listJobs, deleteJob, isPastEndAt, isPastOneShotRoutine, isOneShotRoutine, setJobEnabled, shouldPurgeCompletedOneShotRoutine, jobRunsOnThisDevice, hasAmbiguousDevicePin, routineOwnerDevice, } from './routines.js';
10
10
  /** In-memory cron scheduler that triggers a callback when jobs fire. */
11
11
  export class JobScheduler {
12
12
  jobs = new Map();
@@ -20,7 +20,17 @@ export class JobScheduler {
20
20
  // Trigger-only jobs (no cron schedule) fire via the webhook receiver,
21
21
  // not the cron loop — skip them here. Jobs pinned to another device
22
22
  // (routines are fleet-synced) never enter this machine's cron loop.
23
- if (!config.enabled || !config.schedule || !jobRunsOnThisDevice(config))
23
+ if (!config.enabled || !config.schedule)
24
+ continue;
25
+ // A multi-device pin is a misconfiguration: it used to fire the routine
26
+ // once per listed device. It now fires only on the owner, but say so —
27
+ // silently reinterpreting someone's config is how this went unnoticed.
28
+ if (hasAmbiguousDevicePin(config)) {
29
+ const owner = routineOwnerDevice(config);
30
+ console.warn(`Job '${config.name}' pins ${config.devices.length} devices; a routine runs on exactly one. ` +
31
+ `Firing only on '${owner}'. Fix with: agents routines devices ${config.name} --set ${owner}`);
32
+ }
33
+ if (!jobRunsOnThisDevice(config))
24
34
  continue;
25
35
  if (shouldPurgeCompletedOneShotRoutine(config)) {
26
36
  deleteJob(config.name);
@@ -193,11 +193,10 @@ export declare function rekeyStatus(): {
193
193
  * Throws when the item cannot be reached — on macOS, when the signed helper is
194
194
  * unavailable. That is deliberate: this primitive gates destructive writes as
195
195
  * well as reads. Through `bundleExists()` it guards the `--force` overwrite
196
- * checks in `agents secrets create` (`../../commands/secrets.ts`), the
197
- * bundle-rename purge (`./bundles.ts`), the pull-rollback bookkeeping
198
- * (`./sync.ts`), and the reuse-never-overwrite rule for `R2_SYNC_ENC_KEY`
199
- * (`../session/sync/provision.ts`). A false "absent" silently disarms every one
200
- * of them, so an unreachable keychain must fail loudly rather than answer "no".
196
+ * checks in `agents secrets create` (`../../commands/secrets.ts`) and the
197
+ * bundle-rename purge (`./bundles.ts`), and the pull-rollback bookkeeping
198
+ * (`./sync.ts`). A false "absent" silently disarms every one of them, so an
199
+ * unreachable keychain must fail loudly rather than answer "no".
201
200
  *
202
201
  * Tests needing this path without a helper install a backend via
203
202
  * `setKeychainBackendForTest()`, which short-circuits on the next line.
@@ -680,11 +680,10 @@ export function rekeyStatus() {
680
680
  * Throws when the item cannot be reached — on macOS, when the signed helper is
681
681
  * unavailable. That is deliberate: this primitive gates destructive writes as
682
682
  * well as reads. Through `bundleExists()` it guards the `--force` overwrite
683
- * checks in `agents secrets create` (`../../commands/secrets.ts`), the
684
- * bundle-rename purge (`./bundles.ts`), the pull-rollback bookkeeping
685
- * (`./sync.ts`), and the reuse-never-overwrite rule for `R2_SYNC_ENC_KEY`
686
- * (`../session/sync/provision.ts`). A false "absent" silently disarms every one
687
- * of them, so an unreachable keychain must fail loudly rather than answer "no".
683
+ * checks in `agents secrets create` (`../../commands/secrets.ts`) and the
684
+ * bundle-rename purge (`./bundles.ts`), and the pull-rollback bookkeeping
685
+ * (`./sync.ts`). A false "absent" silently disarms every one of them, so an
686
+ * unreachable keychain must fail loudly rather than answer "no".
688
687
  *
689
688
  * Tests needing this path without a helper install a backend via
690
689
  * `setKeychainBackendForTest()`, which short-circuits on the next line.
@@ -265,6 +265,14 @@ export declare function activeStatusFromCloudStatus(status: CloudTaskStatus): Ac
265
265
  export interface ActiveQueryOptions {
266
266
  /** Skip the `ps` scan for ad-hoc headless agents. */
267
267
  skipHeadless?: boolean;
268
+ /**
269
+ * A `--local` query: this machine only. Never dial a remote-host teammate
270
+ * (one dispatched via `agents teams add --device`) over ssh — report its
271
+ * last-persisted meta.json state instead of polling it. RUSH-2118: without
272
+ * this, `agents sessions --active --local` still fired one ssh round-trip
273
+ * per remote-host teammate (finished or not) on every call.
274
+ */
275
+ localOnly?: boolean;
268
276
  }
269
277
  /**
270
278
  * A live process can only borrow an indexed session file if that transcript
@@ -421,8 +429,14 @@ export declare function computeLiveSignals(kind: string, sessionFile: string | u
421
429
  * and truncates. Exported for tests.
422
430
  */
423
431
  export declare function summarizeMission(prompt: string | null | undefined): string | undefined;
424
- /** Live teams teammates. Reuses AgentManager which already polls PIDs via `kill -0`. */
425
- export declare function listTeamsActive(): Promise<ActiveSession[]>;
432
+ /**
433
+ * Live teams teammates. Reuses AgentManager which already polls PIDs via
434
+ * `kill -0`. `localOnly` (RUSH-2118) skips the ssh round-trip AgentManager
435
+ * would otherwise issue for every distributed (remote-host) teammate.
436
+ */
437
+ export declare function listTeamsActive(opts?: {
438
+ localOnly?: boolean;
439
+ }): Promise<ActiveSession[]>;
426
440
  /** Live editor-terminal agents across every IDE window. */
427
441
  export declare function listTerminalsActive(): Promise<ActiveSession[]>;
428
442
  /** Cloud tasks still in a non-terminal state. `tasks.db` may not exist; that's fine. */
@@ -697,9 +697,13 @@ export function summarizeMission(prompt) {
697
697
  return undefined;
698
698
  return cleaned.length > 80 ? `${cleaned.slice(0, 79)}…` : cleaned;
699
699
  }
700
- /** Live teams teammates. Reuses AgentManager which already polls PIDs via `kill -0`. */
701
- export async function listTeamsActive() {
702
- const mgr = new AgentManager();
700
+ /**
701
+ * Live teams teammates. Reuses AgentManager which already polls PIDs via
702
+ * `kill -0`. `localOnly` (RUSH-2118) skips the ssh round-trip AgentManager
703
+ * would otherwise issue for every distributed (remote-host) teammate.
704
+ */
705
+ export async function listTeamsActive(opts = {}) {
706
+ const mgr = new AgentManager(undefined, undefined, undefined, undefined, undefined, opts.localOnly ?? false);
703
707
  const running = await mgr.listRunning();
704
708
  return running.map((a) => {
705
709
  // The teammate's OWN transcript is `remoteSessionId` (captured from its first
@@ -1438,7 +1442,7 @@ export async function listTmuxAgentSessions() {
1438
1442
  export async function getActiveSessions(opts = {}) {
1439
1443
  const [tmuxAgents, teams, terminals, cloud] = await Promise.all([
1440
1444
  listTmuxAgentSessions().catch(() => []),
1441
- listTeamsActive().catch(() => []),
1445
+ listTeamsActive({ localOnly: opts.localOnly }).catch(() => []),
1442
1446
  listTerminalsActive().catch(() => []),
1443
1447
  Promise.resolve(listCloudActive()),
1444
1448
  ]);
@@ -19,12 +19,16 @@
19
19
  */
20
20
  import * as fs from 'fs';
21
21
  import * as path from 'path';
22
+ import * as crypto from 'crypto';
22
23
  import { SYNC_AGENTS, mirrorPath } from './sync/agents.js';
23
- import { hashContent } from './sync/manifest.js';
24
24
  import { redactSecrets } from '../redact.js';
25
25
  import { encryptTranscript, decryptTranscriptBody } from './sync/transcript-crypto.js';
26
26
  export const BUNDLE_KIND = 'agents-session-bundle';
27
27
  export const BUNDLE_VERSION = 1;
28
+ /** SHA-256 of a file body, used for the bundle's dedup/conflict check on import. */
29
+ function hashContent(content) {
30
+ return crypto.createHash('sha256').update(content).digest('hex');
31
+ }
28
32
  /** Look up the sync spec for an agent id (undefined → agent not sync-representable). */
29
33
  export function specForAgent(agentId) {
30
34
  return SYNC_AGENTS.find(s => s.id === agentId);
@@ -1,12 +1,4 @@
1
1
  import type { SessionMeta } from './types.js';
2
- /**
3
- * The command run on each peer: answer for itself, as JSON, without recursing.
4
- * `forwardedArgs` carry the caller's own query/filters (already including the
5
- * leading `sessions` and a `--json`) so each peer returns a comparable slice.
6
- * A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
7
- * there, where `bash -lc` is not a command); every other OS keeps `bash -lc`.
8
- */
9
- export declare function remoteListCommand(forwardedArgs: string[], os?: string): string;
10
2
  /**
11
3
  * Parse a peer's `sessions --json` stdout into `SessionMeta[]`, tagging each
12
4
  * with `machine`. Defensive against version skew / partial output: non-JSON or
@@ -17,15 +9,9 @@ export declare function remoteListCommand(forwardedArgs: string[], os?: string):
17
9
  */
18
10
  export declare function parseRemoteList(stdout: string, machine: string): SessionMeta[];
19
11
  export declare function parseRemoteListPayload(stdout: string, machine: string, safeResolver?: boolean): {
20
- sessions: SessionMeta[];
12
+ items: SessionMeta[];
21
13
  valid: boolean;
22
14
  };
23
- /** Convert one completed peer process into the exact aggregation result. This
24
- * is the production parent/peer seam and is exercised with real child output. */
25
- export declare function remoteListCaptureResult(code: number | null, stdout: string, machine: string, display: string, safeResolver?: boolean): {
26
- sessions: SessionMeta[];
27
- unreachable?: string;
28
- };
29
15
  export interface RemoteListResult {
30
16
  sessions: SessionMeta[];
31
17
  /** How many peer machines we attempted to reach (drives the empty-fleet tip). */