@phnx-labs/agents-cli 1.20.93 → 1.21.1

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 (128) hide show
  1. package/CHANGELOG.md +290 -0
  2. package/README.md +4 -1
  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/feed.js +37 -23
  8. package/dist/commands/mailboxes.js +39 -1
  9. package/dist/commands/message.js +12 -1
  10. package/dist/commands/monitors.js +8 -6
  11. package/dist/commands/packages.js +14 -5
  12. package/dist/commands/projects.d.ts +28 -0
  13. package/dist/commands/projects.js +173 -47
  14. package/dist/commands/routines.js +27 -1
  15. package/dist/commands/rules.js +6 -0
  16. package/dist/commands/sessions.d.ts +17 -0
  17. package/dist/commands/sessions.js +16 -8
  18. package/dist/commands/setup-browser.js +5 -1
  19. package/dist/commands/setup-fleet.js +5 -0
  20. package/dist/commands/setup-preferences.d.ts +53 -0
  21. package/dist/commands/setup-preferences.js +142 -0
  22. package/dist/commands/setup.js +8 -9
  23. package/dist/commands/ssh.js +220 -10
  24. package/dist/commands/sync.d.ts +2 -2
  25. package/dist/commands/sync.js +4 -9
  26. package/dist/commands/watchdog.js +26 -3
  27. package/dist/index.js +22 -6
  28. package/dist/lib/activity.d.ts +13 -0
  29. package/dist/lib/activity.js +22 -2
  30. package/dist/lib/auto-dispatch.d.ts +6 -1
  31. package/dist/lib/auto-dispatch.js +7 -2
  32. package/dist/lib/beta.d.ts +1 -1
  33. package/dist/lib/beta.js +1 -1
  34. package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
  35. package/dist/lib/channels/resolve.d.ts +18 -1
  36. package/dist/lib/channels/resolve.js +15 -5
  37. package/dist/lib/daemon.d.ts +13 -0
  38. package/dist/lib/daemon.js +80 -60
  39. package/dist/lib/device-config.d.ts +82 -0
  40. package/dist/lib/device-config.js +296 -0
  41. package/dist/lib/event-provenance.d.ts +19 -0
  42. package/dist/lib/event-provenance.js +48 -0
  43. package/dist/lib/events.d.ts +2 -2
  44. package/dist/lib/events.js +4 -53
  45. package/dist/lib/feed-broadcast.d.ts +41 -5
  46. package/dist/lib/feed-broadcast.js +110 -12
  47. package/dist/lib/feed-post.d.ts +16 -2
  48. package/dist/lib/feed-post.js +35 -7
  49. package/dist/lib/feed.d.ts +1 -1
  50. package/dist/lib/feed.js +3 -1
  51. package/dist/lib/git.d.ts +13 -2
  52. package/dist/lib/git.js +38 -6
  53. package/dist/lib/hooks.d.ts +16 -1
  54. package/dist/lib/hooks.js +61 -1
  55. package/dist/lib/linear-project-counts.d.ts +72 -5
  56. package/dist/lib/linear-project-counts.js +90 -6
  57. package/dist/lib/linear-projects.d.ts +12 -0
  58. package/dist/lib/linear-projects.js +30 -0
  59. package/dist/lib/mailbox-gc.js +30 -7
  60. package/dist/lib/mailbox.d.ts +14 -1
  61. package/dist/lib/mailbox.js +35 -3
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  63. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  64. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  65. package/dist/lib/menubar/install-menubar.js +35 -44
  66. package/dist/lib/migrate.js +1 -1
  67. package/dist/lib/models.js +21 -11
  68. package/dist/lib/monitors/config.d.ts +1 -1
  69. package/dist/lib/monitors/dispatch.d.ts +6 -2
  70. package/dist/lib/monitors/dispatch.js +10 -15
  71. package/dist/lib/notify.d.ts +42 -6
  72. package/dist/lib/notify.js +41 -32
  73. package/dist/lib/overdue.d.ts +1 -1
  74. package/dist/lib/overdue.js +54 -19
  75. package/dist/lib/project-import.d.ts +96 -0
  76. package/dist/lib/project-import.js +171 -0
  77. package/dist/lib/remote-agents-json.d.ts +14 -1
  78. package/dist/lib/remote-agents-json.js +21 -3
  79. package/dist/lib/routines.d.ts +53 -0
  80. package/dist/lib/routines.js +105 -4
  81. package/dist/lib/rules/run-sync.d.ts +18 -0
  82. package/dist/lib/rules/run-sync.js +92 -0
  83. package/dist/lib/scheduler.js +12 -2
  84. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  85. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  86. package/dist/lib/secrets/index.d.ts +4 -5
  87. package/dist/lib/secrets/index.js +4 -5
  88. package/dist/lib/session/active.d.ts +16 -2
  89. package/dist/lib/session/active.js +8 -4
  90. package/dist/lib/session/bundle.js +5 -1
  91. package/dist/lib/session/remote-list.d.ts +1 -15
  92. package/dist/lib/session/remote-list.js +22 -111
  93. package/dist/lib/session/sync/config.d.ts +13 -5
  94. package/dist/lib/session/sync/config.js +21 -13
  95. package/dist/lib/startup/command-registry.d.ts +0 -2
  96. package/dist/lib/startup/command-registry.js +1 -5
  97. package/dist/lib/state.d.ts +7 -2
  98. package/dist/lib/state.js +85 -10
  99. package/dist/lib/sync-umbrella.d.ts +11 -23
  100. package/dist/lib/sync-umbrella.js +14 -45
  101. package/dist/lib/teams/agents.d.ts +35 -3
  102. package/dist/lib/teams/agents.js +52 -8
  103. package/dist/lib/teams/scheduler.d.ts +29 -2
  104. package/dist/lib/teams/scheduler.js +60 -16
  105. package/dist/lib/types.d.ts +23 -1
  106. package/package.json +1 -2
  107. package/dist/commands/drive.d.ts +0 -10
  108. package/dist/commands/drive.js +0 -183
  109. package/dist/commands/hq.d.ts +0 -2
  110. package/dist/commands/hq.js +0 -58
  111. package/dist/commands/sessions-sync.d.ts +0 -17
  112. package/dist/commands/sessions-sync.js +0 -135
  113. package/dist/commands/sync-provision.d.ts +0 -23
  114. package/dist/commands/sync-provision.js +0 -107
  115. package/dist/lib/drive-sync.d.ts +0 -45
  116. package/dist/lib/drive-sync.js +0 -238
  117. package/dist/lib/hq/floor.d.ts +0 -87
  118. package/dist/lib/hq/floor.js +0 -243
  119. package/dist/lib/session/sync/crdt.d.ts +0 -44
  120. package/dist/lib/session/sync/crdt.js +0 -119
  121. package/dist/lib/session/sync/manifest.d.ts +0 -62
  122. package/dist/lib/session/sync/manifest.js +0 -100
  123. package/dist/lib/session/sync/provision.d.ts +0 -49
  124. package/dist/lib/session/sync/provision.js +0 -91
  125. package/dist/lib/session/sync/r2.d.ts +0 -32
  126. package/dist/lib/session/sync/r2.js +0 -121
  127. package/dist/lib/session/sync/sync.d.ts +0 -106
  128. package/dist/lib/session/sync/sync.js +0 -374
package/dist/lib/state.js CHANGED
@@ -26,7 +26,9 @@ import * as fs from 'fs';
26
26
  import * as path from 'path';
27
27
  import * as os from 'os';
28
28
  import * as yaml from 'yaml';
29
+ import { execFileSync } from 'child_process';
29
30
  import { ensureLockTarget, atomicWriteFileSync, withFileLock } from './fs-atomic.js';
31
+ import { DEFAULT_SYSTEM_REPO, systemRepoSlug } from './types.js';
30
32
  import { machineId } from './machine-id.js';
31
33
  const HOME = process.env.HOME ?? os.homedir();
32
34
  /**
@@ -116,7 +118,6 @@ const PACKAGES_DIR = path.join(CACHE_DIR, 'packages');
116
118
  // They live at the user-root so they're git-tracked as source of truth.
117
119
  const PLUGINS_DIR = path.join(USER_AGENTS_DIR, 'plugins');
118
120
  const CLOUD_DIR = path.join(CACHE_DIR, 'cloud');
119
- const DRIVE_DIR = path.join(CACHE_DIR, 'drive');
120
121
  const TERMINALS_DIR = path.join(CACHE_DIR, 'terminals');
121
122
  const LOGS_DIR = path.join(CACHE_DIR, 'logs');
122
123
  /** Disposable performance samples (~/.agents/.cache/perf/) — safe to wipe. */
@@ -144,9 +145,16 @@ const USER_SUBAGENTS_DIR = path.join(USER_AGENTS_DIR, 'subagents');
144
145
  const USER_WORKFLOWS_DIR = path.join(USER_AGENTS_DIR, 'workflows');
145
146
  const USER_SECRETS_DIR = path.join(USER_AGENTS_DIR, 'secrets');
146
147
  const USER_PROMPTCUTS_FILE = path.join(USER_AGENTS_DIR, 'hooks', 'promptcuts.yaml');
147
- const META_HEADER = `# agents-cli metadata
148
+ /**
149
+ * Header prepended to every agents.yaml the CLI writes (central and per-device
150
+ * docs). Carries the yaml-language-server schema hint so editors validate the
151
+ * file against `schema/agents-yaml.schema.json`. Exported so
152
+ * `lib/device-config.ts` writes a sibling device's doc with the same header.
153
+ */
154
+ export const META_HEADER = `# agents-cli metadata
148
155
  # Auto-generated - do not edit manually
149
156
  # https://github.com/phnx-labs/agents-cli
157
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agents-cli/main/apps/cli/schema/agents-yaml.schema.json
150
158
 
151
159
  `;
152
160
  // ─── Root getters ─────────────────────────────────────────────────────────────
@@ -190,13 +198,69 @@ export function getOptionalUserAgentsDir() {
190
198
  }
191
199
  return USER_AGENTS_DIR;
192
200
  }
201
+ /**
202
+ * Origin `owner/repo` slug (lowercased, `.git` stripped) of a git checkout, or
203
+ * null when `dir` isn't a git repo / has no origin. Extracts the slug from any
204
+ * remote URL form: `git@host:owner/repo.git`, `https://host/owner/repo.git`,
205
+ * `ssh://git@host/owner/repo`. Sync (mirrors readGitConfigUser in git.ts) so it
206
+ * can be used from the synchronous getProjectAgentsDir walk.
207
+ */
208
+ function gitOriginSlug(dir) {
209
+ let url;
210
+ try {
211
+ url = execFileSync('git', ['-C', dir, 'config', '--get', 'remote.origin.url'], {
212
+ stdio: ['ignore', 'pipe', 'ignore'],
213
+ }).toString().trim();
214
+ }
215
+ catch {
216
+ return null;
217
+ }
218
+ if (!url)
219
+ return null;
220
+ const m = url.replace(/\.git$/i, '').match(/[:/]([^/:]+\/[^/]+)$/);
221
+ return m ? m[1].toLowerCase() : null;
222
+ }
223
+ /** Slugs of the user + system DotAgents repos — a checkout of any of these is not a project layer. */
224
+ function canonicalDotAgentsRepoSlugs() {
225
+ const slugs = new Set([systemRepoSlug(DEFAULT_SYSTEM_REPO).toLowerCase()]);
226
+ for (const dir of [USER_AGENTS_DIR, SYSTEM_AGENTS_DIR]) {
227
+ const slug = gitOriginSlug(dir);
228
+ if (slug)
229
+ slugs.add(slug);
230
+ }
231
+ return slugs;
232
+ }
233
+ /**
234
+ * True when `agentsPath` is itself a git checkout of the user's or system's
235
+ * DotAgents repo — i.e. a *clone* of the very repo whose rules already load as
236
+ * the user/system layer (e.g. `git clone …/.agents.git ~/src/github.com/<you>/.agents`).
237
+ * Such a clone must NOT also be treated as a *project* layer: because project
238
+ * outranks user, a stale clone would silently shadow the live user rules by
239
+ * filename and plant a compiled AGENTS.md in an ancestor dir (RUSH-2037).
240
+ *
241
+ * A legitimate project layer is a plain subdirectory of a project and is never
242
+ * itself a git-repo root, so the cheap `.git` gate skips the (git-spawning)
243
+ * origin comparison for the common case — only a `.agents/` that is its own
244
+ * checkout pays it, and even then it's kept unless its origin matches the
245
+ * user/system DotAgents repo (an unrelated repo checked out at `.agents/`,
246
+ * or a project's own versioned `.agents/`, stays a valid project layer).
247
+ */
248
+ function isUserOrSystemRepoCheckout(agentsPath) {
249
+ if (!fs.existsSync(path.join(agentsPath, '.git')))
250
+ return false;
251
+ const origin = gitOriginSlug(agentsPath);
252
+ if (!origin)
253
+ return false;
254
+ return canonicalDotAgentsRepoSlugs().has(origin);
255
+ }
193
256
  /** Walk up from startPath to find a project-scoped .agents/ directory (skipping both roots). */
194
257
  export function getProjectAgentsDir(startPath = process.cwd()) {
195
258
  let dir = path.resolve(startPath);
196
259
  while (true) {
197
260
  const agentsPath = path.join(dir, '.agents');
198
261
  if (fs.existsSync(agentsPath) && fs.statSync(agentsPath).isDirectory()) {
199
- if (!isSamePath(agentsPath, SYSTEM_AGENTS_DIR) && !isSamePath(agentsPath, USER_AGENTS_DIR)) {
262
+ if (!isSamePath(agentsPath, SYSTEM_AGENTS_DIR) && !isSamePath(agentsPath, USER_AGENTS_DIR)
263
+ && !isUserOrSystemRepoCheckout(agentsPath)) {
200
264
  return agentsPath;
201
265
  }
202
266
  }
@@ -418,8 +482,6 @@ export function getProjectPluginsDir(cwd = process.cwd()) {
418
482
  return null;
419
483
  return path.join(projectAgentsDir, 'plugins');
420
484
  }
421
- /** Path to synced remote session data (~/.agents/.cache/drive/). */
422
- export function getDriveDir() { return DRIVE_DIR; }
423
485
  /** Path to soft-deleted resources (~/.agents/.history/trash/). */
424
486
  export function getTrashDir() { return TRASH_DIR; }
425
487
  /** Path to local session indexer storage (~/.agents/.history/sessions/). */
@@ -673,8 +735,9 @@ function writeIfChanged(filePath, content) {
673
735
  /**
674
736
  * Partition the in-memory Meta across three files by sync-domain:
675
737
  * - central `~/.agents/agents.yaml` — portable, everything else
738
+ * (including the user-scope `config:` block)
676
739
  * - device `~/.agents/devices/<machine>/agents.yaml` — `agents:` pins +
677
- * `defaultBrowserProfile:` (both per-device)
740
+ * `defaultBrowserProfile:` + device-scope `config:` (all per-device)
678
741
  * - history `~/.agents/.history/version-resources.json` — `versions:` (machine-local)
679
742
  * All callers funnel through writeMeta → here, so nothing else changes. Empty
680
743
  * `agents:` / `versions:` are not written (no empty committed files).
@@ -736,7 +799,7 @@ function serializeCentral(central) {
736
799
  return isEmpty ? META_HEADER : doc.toString({ collectionStyle: 'block' });
737
800
  }
738
801
  function writeMetaUnlocked(meta) {
739
- const { agents, isolatedAgents, versions, defaultBrowserProfile, ...central } = meta;
802
+ const { agents, isolatedAgents, versions, defaultBrowserProfile, deviceConfig, ...central } = meta;
740
803
  // Write the machine-local files FIRST, then strip central — so a crash mid-write
741
804
  // never removes pins/versions from central before they're persisted elsewhere.
742
805
  const devicePath = getDeviceMetaPath();
@@ -747,9 +810,13 @@ function writeMetaUnlocked(meta) {
747
810
  // it does not have.
748
811
  const hasIsolatedAgents = !!isolatedAgents && Object.keys(isolatedAgents).length > 0;
749
812
  const hasDefaultBrowser = !!defaultBrowserProfile;
750
- if (hasAgents || hasIsolatedAgents || hasDefaultBrowser) {
751
- // Device-local doc carries `agents:` pins and `defaultBrowserProfile:` both
752
- // are per-machine and must never land in central agents.yaml (which syncs).
813
+ // Device-scope config (`maxAgents`, `schedulerEnabled`, ) is per-machine, so it
814
+ // rides the device doc under `config:` never the central doc that syncs.
815
+ const hasDeviceConfig = !!deviceConfig && Object.keys(deviceConfig).length > 0;
816
+ if (hasAgents || hasIsolatedAgents || hasDefaultBrowser || hasDeviceConfig) {
817
+ // Device-local doc carries `agents:` pins, `defaultBrowserProfile:`, and the
818
+ // device-scope `config:` — all per-machine and must never land in central
819
+ // agents.yaml (which syncs).
753
820
  const deviceDoc = {};
754
821
  if (hasAgents)
755
822
  deviceDoc.agents = agents;
@@ -757,6 +824,8 @@ function writeMetaUnlocked(meta) {
757
824
  deviceDoc.isolatedAgents = isolatedAgents;
758
825
  if (hasDefaultBrowser)
759
826
  deviceDoc.defaultBrowserProfile = defaultBrowserProfile;
827
+ if (hasDeviceConfig)
828
+ deviceDoc.config = deviceConfig;
760
829
  fs.mkdirSync(path.dirname(devicePath), { recursive: true });
761
830
  writeIfChanged(devicePath, META_HEADER + yaml.stringify(deviceDoc));
762
831
  }
@@ -782,6 +851,10 @@ function writeMetaUnlocked(meta) {
782
851
  * still has pins)
783
852
  * - `defaultBrowserProfile:` from the device file (device is the sole source;
784
853
  * the field is stripped from central on write, so nothing to merge against)
854
+ * - `config:` from the device file into `deviceConfig` (device is the sole
855
+ * source for device-scope config, same routing as `defaultBrowserProfile`;
856
+ * the in-memory field is named `deviceConfig` so it can never collide with
857
+ * the user-scope `config:` central carries)
785
858
  * - `versions:` from the history JSON (wholesale replace; falls back to
786
859
  * whatever central carried when the history file doesn't exist yet)
787
860
  */
@@ -796,6 +869,8 @@ function overlayMachineLocal(meta) {
796
869
  meta.isolatedAgents = { ...meta.isolatedAgents, ...dm.isolatedAgents };
797
870
  if (dm?.defaultBrowserProfile)
798
871
  meta.defaultBrowserProfile = dm.defaultBrowserProfile;
872
+ if (dm?.config)
873
+ meta.deviceConfig = dm.config;
799
874
  }
800
875
  catch { /* ignore malformed device file */ }
801
876
  }
@@ -2,25 +2,23 @@
2
2
  * Umbrella `agents sync` orchestration — "make this machine current".
3
3
  *
4
4
  * Bare `agents sync` fetches the config repos then reconciles them into every
5
- * installed agent's version home. Secrets and sessions are opt-in stages
6
- * (`--secrets` / `--sessions`) — see `planUmbrellaStages` for why they're off by
7
- * default. Each stage is an existing exported library function; this module only
8
- * sequences them and decides — from the flags — which stages run. The planner is
9
- * pure so the flag matrix is unit-tested without any I/O.
5
+ * installed agent's version home. Secrets are an opt-in stage (`--secrets`) —
6
+ * see `planUmbrellaStages` for why it's off by default. Each stage is an
7
+ * existing exported library function; this module only sequences them and
8
+ * decides — from the flags — which stages run. The planner is pure so the
9
+ * flag matrix is unit-tested without any I/O.
10
10
  *
11
11
  * Stage backends:
12
12
  * repos -> git pull of ~/.agents + enabled ~/.agents-* extras (pullRepo)
13
13
  * secrets -> listRemoteBundles + pullBundle (needs a passphrase; skipped
14
14
  * cleanly when none is available — tokenized non-interactive auth
15
15
  * arrives with `agents login`, #366/#367)
16
- * sessions -> syncSessions(), gated by the session-sync beta opt-in + isSyncConfigured(), like the daemon
17
16
  * reconcile-> refresh({ skipPrompts }) — re-materialize resources into homes
18
17
  */
19
- /** The five umbrella flags off `agents sync`. */
18
+ /** The umbrella flags off `agents sync`. */
20
19
  export interface UmbrellaFlags {
21
20
  repos?: boolean;
22
21
  secrets?: boolean;
23
- sessions?: boolean;
24
22
  cloud?: boolean;
25
23
  local?: boolean;
26
24
  }
@@ -28,7 +26,6 @@ export interface UmbrellaFlags {
28
26
  export interface UmbrellaPlan {
29
27
  fetchRepos: boolean;
30
28
  fetchSecrets: boolean;
31
- fetchSessions: boolean;
32
29
  reconcile: boolean;
33
30
  }
34
31
  /**
@@ -36,14 +33,12 @@ export interface UmbrellaPlan {
36
33
  * bare (no flags) fetch repos, then reconcile
37
34
  * --local reconcile only, no fetch
38
35
  * --cloud fetch repos (or the selected subset), skip reconcile
39
- * --repos/--secrets/... fetch only the selected types, then reconcile
36
+ * --repos/--secrets fetch only the selected types, then reconcile
40
37
  * `--local` wins over everything; `--cloud` suppresses reconcile.
41
38
  *
42
- * Secrets and sessions are NOT part of the bare default — they are opt-in via
43
- * `--secrets` / `--sessions`. Pulling every secret bundle onto the machine on a
44
- * bare `agents sync` is more blast radius than the verb should carry by
45
- * default, and session transcripts are queryable on demand (`agents sessions
46
- * --host <machine>`) so they don't need eager mirroring.
39
+ * Secrets are NOT part of the bare default — they are opt-in via `--secrets`.
40
+ * Pulling every secret bundle onto the machine on a bare `agents sync` is more
41
+ * blast radius than the verb should carry by default.
47
42
  */
48
43
  export declare function planUmbrellaStages(f: UmbrellaFlags): UmbrellaPlan;
49
44
  export interface UmbrellaResult {
@@ -58,13 +53,6 @@ export interface UmbrellaResult {
58
53
  reason?: string;
59
54
  errors: string[];
60
55
  };
61
- sessions?: {
62
- ran: boolean;
63
- pushed: number;
64
- pulled: number;
65
- merged: number;
66
- error?: string;
67
- };
68
56
  devices?: {
69
57
  synced: number;
70
58
  pending: number;
@@ -82,7 +70,7 @@ export interface RunUmbrellaArgs {
82
70
  passphrase?: string;
83
71
  }
84
72
  /**
85
- * Execute the planned stages in order: repos -> secrets -> sessions -> reconcile.
73
+ * Execute the planned stages in order: repos -> secrets -> reconcile.
86
74
  * A failure in one fetch stage is recorded and does not abort the others or the
87
75
  * reconcile — `agents sync` should make as much current as it can in one pass.
88
76
  */
@@ -2,18 +2,17 @@
2
2
  * Umbrella `agents sync` orchestration — "make this machine current".
3
3
  *
4
4
  * Bare `agents sync` fetches the config repos then reconciles them into every
5
- * installed agent's version home. Secrets and sessions are opt-in stages
6
- * (`--secrets` / `--sessions`) — see `planUmbrellaStages` for why they're off by
7
- * default. Each stage is an existing exported library function; this module only
8
- * sequences them and decides — from the flags — which stages run. The planner is
9
- * pure so the flag matrix is unit-tested without any I/O.
5
+ * installed agent's version home. Secrets are an opt-in stage (`--secrets`) —
6
+ * see `planUmbrellaStages` for why it's off by default. Each stage is an
7
+ * existing exported library function; this module only sequences them and
8
+ * decides — from the flags — which stages run. The planner is pure so the
9
+ * flag matrix is unit-tested without any I/O.
10
10
  *
11
11
  * Stage backends:
12
12
  * repos -> git pull of ~/.agents + enabled ~/.agents-* extras (pullRepo)
13
13
  * secrets -> listRemoteBundles + pullBundle (needs a passphrase; skipped
14
14
  * cleanly when none is available — tokenized non-interactive auth
15
15
  * arrives with `agents login`, #366/#367)
16
- * sessions -> syncSessions(), gated by the session-sync beta opt-in + isSyncConfigured(), like the daemon
17
16
  * reconcile-> refresh({ skipPrompts }) — re-materialize resources into homes
18
17
  */
19
18
  import { pullRepo } from './git.js';
@@ -24,34 +23,31 @@ import { listRemoteBundles, pullBundle } from './secrets/sync.js';
24
23
  * bare (no flags) fetch repos, then reconcile
25
24
  * --local reconcile only, no fetch
26
25
  * --cloud fetch repos (or the selected subset), skip reconcile
27
- * --repos/--secrets/... fetch only the selected types, then reconcile
26
+ * --repos/--secrets fetch only the selected types, then reconcile
28
27
  * `--local` wins over everything; `--cloud` suppresses reconcile.
29
28
  *
30
- * Secrets and sessions are NOT part of the bare default — they are opt-in via
31
- * `--secrets` / `--sessions`. Pulling every secret bundle onto the machine on a
32
- * bare `agents sync` is more blast radius than the verb should carry by
33
- * default, and session transcripts are queryable on demand (`agents sessions
34
- * --host <machine>`) so they don't need eager mirroring.
29
+ * Secrets are NOT part of the bare default — they are opt-in via `--secrets`.
30
+ * Pulling every secret bundle onto the machine on a bare `agents sync` is more
31
+ * blast radius than the verb should carry by default.
35
32
  */
36
33
  export function planUmbrellaStages(f) {
37
34
  if (f.local) {
38
- return { fetchRepos: false, fetchSecrets: false, fetchSessions: false, reconcile: true };
35
+ return { fetchRepos: false, fetchSecrets: false, reconcile: true };
39
36
  }
40
- const anySelector = !!(f.repos || f.secrets || f.sessions);
37
+ const anySelector = !!(f.repos || f.secrets);
41
38
  if (anySelector) {
42
39
  return {
43
40
  fetchRepos: !!f.repos,
44
41
  fetchSecrets: !!f.secrets,
45
- fetchSessions: !!f.sessions,
46
42
  reconcile: !f.cloud,
47
43
  };
48
44
  }
49
45
  // No per-type selector: bare = repos + reconcile; --cloud = repos, no
50
- // reconcile. Secrets/sessions stay off unless explicitly selected above.
51
- return { fetchRepos: true, fetchSecrets: false, fetchSessions: false, reconcile: !f.cloud };
46
+ // reconcile. Secrets stay off unless explicitly selected above.
47
+ return { fetchRepos: true, fetchSecrets: false, reconcile: !f.cloud };
52
48
  }
53
49
  /**
54
- * Execute the planned stages in order: repos -> secrets -> sessions -> reconcile.
50
+ * Execute the planned stages in order: repos -> secrets -> reconcile.
55
51
  * A failure in one fetch stage is recorded and does not abort the others or the
56
52
  * reconcile — `agents sync` should make as much current as it can in one pass.
57
53
  */
@@ -110,33 +106,6 @@ export async function runUmbrellaSync(args) {
110
106
  result.secrets = { pulled, skipped: false, errors };
111
107
  }
112
108
  }
113
- if (plan.fetchSessions) {
114
- // Gate exactly like the daemon: without the `session-sync` beta opt-in (or
115
- // with a missing r2.backups bundle) this is a clean no-op, not an error that
116
- // fails the whole sync.
117
- const { isBetaEnabled } = await import('./beta.js');
118
- const { isSyncConfigured } = await import('./session/sync/config.js');
119
- if (isBetaEnabled('session-sync') && isSyncConfigured()) {
120
- const { syncSessions } = await import('./session/sync/sync.js');
121
- // Record a failure here instead of letting it throw: like the fetchSecrets
122
- // block above, a failure in one fetch stage must not abort the others or
123
- // the reconcile below (see this function's doc comment). Without this,
124
- // syncSessions() throwing — e.g. via the C1 containment reject on a
125
- // malicious peer manifest — would skip `plan.reconcile` entirely.
126
- try {
127
- const r = await syncSessions();
128
- result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
129
- log(`sessions: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}`);
130
- }
131
- catch (err) {
132
- result.sessions = { ran: false, pushed: 0, pulled: 0, merged: 0, error: err.message };
133
- log(`sessions: failed — ${err.message}`);
134
- }
135
- }
136
- else {
137
- result.sessions = { ran: false, pushed: 0, pulled: 0, merged: 0 };
138
- }
139
- }
140
109
  if (plan.reconcile) {
141
110
  const { refresh } = await import('./refresh.js');
142
111
  await refresh({ skipPrompts: yes });
@@ -218,13 +218,30 @@ export declare class AgentProcess {
218
218
  * Uses a per-wave batched snapshot (remotePollSnapshot) when the supervisor's
219
219
  * one-ssh-per-host pre-pass populated it; otherwise falls back to its own
220
220
  * round-trips so a bare `teams status`/`teams logs` is still correct.
221
+ *
222
+ * Only polls a teammate that is plausibly still RUNNING (RUSH-2118). Once a
223
+ * remote teammate reaches a terminal status, the poll that resolved it already
224
+ * mirrored the final log bytes and read the `.exit` sentinel in this SAME
225
+ * function (delta pulled before the exit check below) — the underlying process
226
+ * is gone and can never write more, so there is nothing left to fetch. Without
227
+ * this guard every finished remote teammate still cost one ssh round-trip on
228
+ * EVERY `--active`/`listAll` poll forever, which is what made `agents sessions
229
+ * --active --local` take ~4.3s on a box with 30 completed teammates.
221
230
  */
222
231
  private syncRemoteMirror;
223
232
  /** Reset the local stdout cursor for a newly truncated resume log. */
224
233
  resetLogReadPosition(): number;
225
234
  /** Restore the cursor when a resume transaction puts the prior log back. */
226
235
  restoreLogReadPosition(position: number): void;
227
- readNewEvents(): Promise<void>;
236
+ /**
237
+ * @param opts.skipRemote A `--local` caller (RUSH-2118): never dial a
238
+ * remote-host teammate, not even a still-RUNNING one — report its
239
+ * last-persisted meta.json state as-is. A local-only query is by definition
240
+ * this-machine-only, so it must not issue an ssh round-trip at all.
241
+ */
242
+ readNewEvents(opts?: {
243
+ skipRemote?: boolean;
244
+ }): Promise<void>;
228
245
  /**
229
246
  * Truncate the local mirror to its trailing REMOTE_MIRROR_MAX_BYTES and reset
230
247
  * lastReadPos to the new (smaller) size so the parser doesn't re-read the kept
@@ -244,7 +261,14 @@ export declare class AgentProcess {
244
261
  saveMeta(): Promise<void>;
245
262
  static loadFromDisk(agentId: string, baseDir?: string | null): Promise<AgentProcess | null>;
246
263
  isProcessAlive(): boolean;
247
- updateStatusFromProcess(): Promise<void>;
264
+ /**
265
+ * @param opts.skipRemote A `--local` caller (RUSH-2118): a distributed
266
+ * teammate is never dialed — its in-memory state (already loaded from
267
+ * meta.json) stands as-is, no ssh, no re-save.
268
+ */
269
+ updateStatusFromProcess(opts?: {
270
+ skipRemote?: boolean;
271
+ }): Promise<void>;
248
272
  /**
249
273
  * Recover the teammate's exit status after its process is gone.
250
274
  *
@@ -298,8 +322,16 @@ export declare class AgentManager {
298
322
  private defaultMode;
299
323
  private initPromise;
300
324
  private cloudDispatcher;
325
+ /**
326
+ * A `--local` caller (RUSH-2118): every poll this manager issues skips the
327
+ * ssh round-trip for a distributed (remote-host) teammate, reporting its
328
+ * last-persisted meta.json state instead. Set once at construction so the
329
+ * INITIAL load in doInitialize()/loadExistingAgents() — which polls every
330
+ * teammate before listRunning()/listAll() ever run — honors it too.
331
+ */
332
+ private localOnly;
301
333
  private constructorAgentsDir;
302
- constructor(maxAgents?: number, agentsDir?: string | null, defaultMode?: Mode | null, filterByCwd?: string | null, cleanupAgeDays?: number);
334
+ constructor(maxAgents?: number, agentsDir?: string | null, defaultMode?: Mode | null, filterByCwd?: string | null, cleanupAgeDays?: number, localOnly?: boolean);
303
335
  private initialize;
304
336
  private doInitialize;
305
337
  getDefaultMode(): Mode;
@@ -34,7 +34,9 @@ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
34
34
  import { pullRemoteLogDelta, REMOTE_MIRROR_MAX_BYTES } from '../hosts/progress.js';
35
35
  import { createRemoteWorktree, ensureRemoteRepo } from './remoteWorktree.js';
36
36
  import { getTeam } from './registry.js';
37
- import { resolvePlacement } from './scheduler.js';
37
+ import { resolvePlacement, cappedDevices } from './scheduler.js';
38
+ import { readMaxConcurrentCaps } from '../device-config.js';
39
+ import chalk from 'chalk';
38
40
  let lastMemoryWarnAt = 0;
39
41
  // On macOS, os.freemem() returns only the truly-free pool and ignores the
40
42
  // large inactive+purgeable cache the kernel will reclaim under pressure, so
@@ -669,10 +671,21 @@ export class AgentProcess {
669
671
  * Uses a per-wave batched snapshot (remotePollSnapshot) when the supervisor's
670
672
  * one-ssh-per-host pre-pass populated it; otherwise falls back to its own
671
673
  * round-trips so a bare `teams status`/`teams logs` is still correct.
674
+ *
675
+ * Only polls a teammate that is plausibly still RUNNING (RUSH-2118). Once a
676
+ * remote teammate reaches a terminal status, the poll that resolved it already
677
+ * mirrored the final log bytes and read the `.exit` sentinel in this SAME
678
+ * function (delta pulled before the exit check below) — the underlying process
679
+ * is gone and can never write more, so there is nothing left to fetch. Without
680
+ * this guard every finished remote teammate still cost one ssh round-trip on
681
+ * EVERY `--active`/`listAll` poll forever, which is what made `agents sessions
682
+ * --active --local` take ~4.3s on a box with 30 completed teammates.
672
683
  */
673
684
  async syncRemoteMirror() {
674
685
  if (!this.hostName || !this.hostTarget || !this.remoteLog)
675
686
  return;
687
+ if (this.status !== AgentStatus.RUNNING)
688
+ return;
676
689
  // Pull the new remote bytes and append them to the local mirror the parser
677
690
  // reads. One offset-tail round-trip; nothing to write when the log is quiet.
678
691
  const delta = pullRemoteLogDelta(this.hostTarget, {
@@ -729,7 +742,15 @@ export class AgentProcess {
729
742
  restoreLogReadPosition(position) {
730
743
  this.lastReadPos = position;
731
744
  }
732
- async readNewEvents() {
745
+ /**
746
+ * @param opts.skipRemote A `--local` caller (RUSH-2118): never dial a
747
+ * remote-host teammate, not even a still-RUNNING one — report its
748
+ * last-persisted meta.json state as-is. A local-only query is by definition
749
+ * this-machine-only, so it must not issue an ssh round-trip at all.
750
+ */
751
+ async readNewEvents(opts = {}) {
752
+ if (this.hostName && opts.skipRemote)
753
+ return;
733
754
  // Distributed teammate: mirror the host's new log bytes locally first, then
734
755
  // fall through to the identical local read+parse below.
735
756
  if (this.hostName) {
@@ -984,7 +1005,12 @@ export class AgentProcess {
984
1005
  }
985
1006
  return true;
986
1007
  }
987
- async updateStatusFromProcess() {
1008
+ /**
1009
+ * @param opts.skipRemote A `--local` caller (RUSH-2118): a distributed
1010
+ * teammate is never dialed — its in-memory state (already loaded from
1011
+ * meta.json) stands as-is, no ssh, no re-save.
1012
+ */
1013
+ async updateStatusFromProcess(opts = {}) {
988
1014
  if (!this.pid) {
989
1015
  // Distributed (remote-host) teammates have no local PID by design; their
990
1016
  // lifecycle lives on the host. readNewEvents() mirrors the remote log and
@@ -992,6 +1018,8 @@ export class AgentProcess {
992
1018
  // syncRemoteMirror), so we just persist and return — never the local
993
1019
  // "RUNNING without a PID is impossible" fail path below.
994
1020
  if (this.hostName) {
1021
+ if (opts.skipRemote)
1022
+ return;
995
1023
  await this.readNewEvents();
996
1024
  if (this.status !== AgentStatus.RUNNING && !this.completedAt) {
997
1025
  this.completedAt = this.getLatestEventTime() || this.startedAt || new Date();
@@ -1160,12 +1188,21 @@ export class AgentManager {
1160
1188
  defaultMode;
1161
1189
  initPromise = null;
1162
1190
  cloudDispatcher = null;
1191
+ /**
1192
+ * A `--local` caller (RUSH-2118): every poll this manager issues skips the
1193
+ * ssh round-trip for a distributed (remote-host) teammate, reporting its
1194
+ * last-persisted meta.json state instead. Set once at construction so the
1195
+ * INITIAL load in doInitialize()/loadExistingAgents() — which polls every
1196
+ * teammate before listRunning()/listAll() ever run — honors it too.
1197
+ */
1198
+ localOnly;
1163
1199
  constructorAgentsDir = null;
1164
- constructor(maxAgents = 50, agentsDir = null, defaultMode = null, filterByCwd = null, cleanupAgeDays = 7) {
1200
+ constructor(maxAgents = 50, agentsDir = null, defaultMode = null, filterByCwd = null, cleanupAgeDays = 7, localOnly = false) {
1165
1201
  this.maxAgents = maxAgents;
1166
1202
  this.constructorAgentsDir = agentsDir;
1167
1203
  this.filterByCwd = filterByCwd;
1168
1204
  this.cleanupAgeDays = cleanupAgeDays;
1205
+ this.localOnly = localOnly;
1169
1206
  const resolvedDefaultMode = defaultMode ? normalizeModeValue(defaultMode) : defaultModeFromEnv();
1170
1207
  if (!resolvedDefaultMode) {
1171
1208
  throw new Error(`Invalid default_mode '${defaultMode}'. Use plan, edit, auto, or skip.`);
@@ -1272,7 +1309,7 @@ export class AgentManager {
1272
1309
  continue;
1273
1310
  }
1274
1311
  }
1275
- await agent.updateStatusFromProcess();
1312
+ await agent.updateStatusFromProcess({ skipRemote: this.localOnly });
1276
1313
  this.agents.set(agentId, agent);
1277
1314
  loadedCount++;
1278
1315
  }
@@ -1726,7 +1763,14 @@ export class AgentManager {
1726
1763
  if (!teamMeta)
1727
1764
  return;
1728
1765
  const roster = await this.listByTask(taskName);
1729
- const { device } = resolvePlacement(teamMeta, null, roster);
1766
+ const pool = teamMeta.devices ?? [];
1767
+ const maxConcurrent = pool.length > 1 ? readMaxConcurrentCaps(pool) : undefined;
1768
+ if (maxConcurrent) {
1769
+ for (const c of cappedDevices(pool, roster, maxConcurrent)) {
1770
+ console.error(chalk.dim(`[placement] '${c.device}' excluded from auto-pick — at its agents.max-concurrent cap (${c.running}/${c.cap} running)`));
1771
+ }
1772
+ }
1773
+ const { device } = resolvePlacement(teamMeta, null, roster, { maxConcurrent });
1730
1774
  if (device)
1731
1775
  await this.resolveScheduledPlacement(agent, device, taskName);
1732
1776
  }
@@ -1990,8 +2034,8 @@ export class AgentManager {
1990
2034
  await this.initialize();
1991
2035
  const agents = Array.from(this.agents.values());
1992
2036
  for (const agent of agents) {
1993
- await agent.readNewEvents();
1994
- await agent.updateStatusFromProcess();
2037
+ await agent.readNewEvents({ skipRemote: this.localOnly });
2038
+ await agent.updateStatusFromProcess({ skipRemote: this.localOnly });
1995
2039
  }
1996
2040
  return agents;
1997
2041
  }
@@ -2,6 +2,19 @@
2
2
  export interface PlacementTeam {
3
3
  devices?: string[];
4
4
  }
5
+ /**
6
+ * Optional placement inputs beyond the team + roster. `maxConcurrent` maps a
7
+ * device name to its `agents.max-concurrent` cap (from the device doc — read
8
+ * locally via `readMaxConcurrentCaps`, never probed over SSH). Teams counts
9
+ * the team's OWN roster against the cap (device-global counting would need an
10
+ * SSH probe per candidate — out of the hot path; Factory auto-launch is the
11
+ * device-wide counter). Only the least-loaded AUTO-PICK (cascade step 3)
12
+ * honors caps: an explicit pin or a pool of one is the user's own choice and
13
+ * is never second-guessed.
14
+ */
15
+ export interface PlacementOptions {
16
+ maxConcurrent?: Record<string, number>;
17
+ }
5
18
  /**
6
19
  * A roster entry the load counter reads — the shape any teammate satisfies
7
20
  * (AgentProcess included). `status` is compared against `'running'` (the
@@ -11,19 +24,33 @@ export interface RosterEntry {
11
24
  hostName: string | null;
12
25
  status: string;
13
26
  }
27
+ /**
28
+ * Pool devices excluded from auto-pick because they are at (or over) their
29
+ * `agents.max-concurrent` cap. Returned with the live counts so the caller can
30
+ * state the reason to the user instead of the device silently never winning.
31
+ */
32
+ export declare function cappedDevices(devices: string[], roster: RosterEntry[], maxConcurrent: Record<string, number>): Array<{
33
+ device: string;
34
+ running: number;
35
+ cap: number;
36
+ }>;
14
37
  /**
15
38
  * Pick the least-loaded device from the pool — the one with the fewest RUNNING
16
39
  * teammates currently assigned to it. Ties break by pool order (first wins), so
17
40
  * an empty pool fills round-robin-ish as teammates launch. Pure: counts the
18
41
  * roster, no I/O.
42
+ *
43
+ * With `maxConcurrent`, devices at their cap are excluded; if EVERY device is
44
+ * capped this throws naming each cap and the fix — a loud failure beats a
45
+ * teammate silently landing on a machine its operator capped.
19
46
  */
20
- export declare function pickLeastLoaded(devices: string[], roster: RosterEntry[]): string;
47
+ export declare function pickLeastLoaded(devices: string[], roster: RosterEntry[], maxConcurrent?: Record<string, number>): string;
21
48
  /**
22
49
  * Resolve where a teammate runs. Returns `{ device: null }` for a local run
23
50
  * (no pin, no pool, or the chosen device is the local machine) and
24
51
  * `{ device: <name> }` for a remote placement. See the cascade in the module
25
52
  * header.
26
53
  */
27
- export declare function resolvePlacement(team: PlacementTeam, explicitDevice: string | null, roster: RosterEntry[]): {
54
+ export declare function resolvePlacement(team: PlacementTeam, explicitDevice: string | null, roster: RosterEntry[], opts?: PlacementOptions): {
28
55
  device: string | null;
29
56
  };