@phnx-labs/agents-cli 1.20.92 → 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.
- package/CHANGELOG.md +385 -0
- package/README.md +3 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/beta.js +3 -5
- package/dist/commands/doctor.js +23 -0
- package/dist/commands/events.js +91 -1
- package/dist/commands/exec.js +13 -0
- package/dist/commands/mailboxes.js +39 -1
- package/dist/commands/message.js +12 -1
- package/dist/commands/packages.js +14 -5
- package/dist/commands/projects.d.ts +38 -0
- package/dist/commands/projects.js +355 -48
- package/dist/commands/routines.js +27 -1
- package/dist/commands/rules.js +6 -0
- package/dist/commands/secrets.d.ts +17 -0
- package/dist/commands/secrets.js +198 -7
- package/dist/commands/send.d.ts +14 -12
- package/dist/commands/send.js +105 -35
- package/dist/commands/sessions.d.ts +17 -0
- package/dist/commands/sessions.js +16 -8
- package/dist/commands/setup-browser.js +5 -1
- package/dist/commands/setup-fleet.js +5 -0
- package/dist/commands/setup-preferences.d.ts +53 -0
- package/dist/commands/setup-preferences.js +142 -0
- package/dist/commands/setup.js +8 -9
- package/dist/commands/ssh.js +220 -10
- package/dist/commands/sync.d.ts +2 -2
- package/dist/commands/sync.js +13 -12
- package/dist/commands/view.js +4 -0
- package/dist/commands/watchdog.js +26 -3
- package/dist/index.js +38 -6
- package/dist/lib/activity.d.ts +15 -0
- package/dist/lib/activity.js +26 -2
- package/dist/lib/auto-dispatch.d.ts +6 -1
- package/dist/lib/auto-dispatch.js +7 -2
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/channels/send.d.ts +83 -0
- package/dist/lib/channels/send.js +112 -0
- package/dist/lib/daemon.d.ts +13 -0
- package/dist/lib/daemon.js +80 -60
- package/dist/lib/device-config.d.ts +82 -0
- package/dist/lib/device-config.js +296 -0
- package/dist/lib/event-provenance.d.ts +19 -0
- package/dist/lib/event-provenance.js +48 -0
- package/dist/lib/events-ingest.d.ts +46 -0
- package/dist/lib/events-ingest.js +182 -0
- package/dist/lib/events.d.ts +17 -5
- package/dist/lib/events.js +59 -56
- package/dist/lib/feed.d.ts +1 -1
- package/dist/lib/feed.js +2 -0
- package/dist/lib/git.d.ts +13 -2
- package/dist/lib/git.js +38 -6
- package/dist/lib/linear-project-counts.d.ts +129 -0
- package/dist/lib/linear-project-counts.js +206 -0
- package/dist/lib/linear-projects.d.ts +62 -0
- package/dist/lib/linear-projects.js +144 -0
- package/dist/lib/mailbox-gc.js +30 -7
- package/dist/lib/mailbox.d.ts +14 -1
- package/dist/lib/mailbox.js +35 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +5 -21
- package/dist/lib/menubar/install-menubar.js +35 -44
- package/dist/lib/menubar/notify-desktop.d.ts +17 -2
- package/dist/lib/menubar/notify-desktop.js +8 -2
- package/dist/lib/migrate.js +1 -1
- package/dist/lib/models.js +21 -11
- package/dist/lib/overdue.d.ts +1 -1
- package/dist/lib/overdue.js +54 -19
- package/dist/lib/project-import.d.ts +96 -0
- package/dist/lib/project-import.js +171 -0
- package/dist/lib/project-probe.d.ts +75 -0
- package/dist/lib/project-probe.js +160 -0
- package/dist/lib/project-resources.d.ts +8 -0
- package/dist/lib/project-resources.js +31 -3
- package/dist/lib/project-status.d.ts +32 -1
- package/dist/lib/project-status.js +82 -1
- package/dist/lib/projects.d.ts +6 -0
- package/dist/lib/projects.js +12 -0
- package/dist/lib/remote-agents-json.d.ts +14 -1
- package/dist/lib/remote-agents-json.js +21 -3
- package/dist/lib/routine-notify.d.ts +11 -0
- package/dist/lib/routine-notify.js +22 -0
- package/dist/lib/routines.d.ts +53 -0
- package/dist/lib/routines.js +105 -4
- package/dist/lib/rules/run-sync.d.ts +18 -0
- package/dist/lib/rules/run-sync.js +92 -0
- package/dist/lib/run-notify.js +3 -0
- package/dist/lib/scheduler.js +12 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +53 -10
- package/dist/lib/secrets/index.d.ts +4 -5
- package/dist/lib/secrets/index.js +4 -5
- package/dist/lib/secrets/list-filter.d.ts +20 -5
- package/dist/lib/secrets/list-filter.js +22 -6
- package/dist/lib/secrets/usage-db.d.ts +106 -0
- package/dist/lib/secrets/usage-db.js +236 -0
- package/dist/lib/session/active.d.ts +16 -2
- package/dist/lib/session/active.js +8 -4
- package/dist/lib/session/bundle.js +5 -1
- package/dist/lib/session/remote-active.d.ts +5 -1
- package/dist/lib/session/remote-active.js +4 -1
- package/dist/lib/session/remote-list.d.ts +1 -15
- package/dist/lib/session/remote-list.js +22 -111
- package/dist/lib/session/sync/config.d.ts +13 -5
- package/dist/lib/session/sync/config.js +21 -13
- package/dist/lib/sqlite.js +28 -1
- package/dist/lib/startup/command-registry.d.ts +0 -2
- package/dist/lib/startup/command-registry.js +1 -5
- package/dist/lib/state.d.ts +19 -2
- package/dist/lib/state.js +99 -10
- package/dist/lib/sync-umbrella.d.ts +11 -23
- package/dist/lib/sync-umbrella.js +14 -45
- package/dist/lib/teams/agents.d.ts +35 -3
- package/dist/lib/teams/agents.js +52 -8
- package/dist/lib/teams/scheduler.d.ts +29 -2
- package/dist/lib/teams/scheduler.js +60 -16
- package/dist/lib/types.d.ts +22 -5
- package/dist/lib/versions.d.ts +6 -0
- package/dist/lib/versions.js +6 -4
- package/package.json +1 -2
- package/dist/commands/drive.d.ts +0 -10
- package/dist/commands/drive.js +0 -183
- package/dist/commands/hq.d.ts +0 -2
- package/dist/commands/hq.js +0 -58
- package/dist/commands/sessions-sync.d.ts +0 -17
- package/dist/commands/sessions-sync.js +0 -135
- package/dist/commands/sync-provision.d.ts +0 -23
- package/dist/commands/sync-provision.js +0 -107
- package/dist/lib/drive-sync.d.ts +0 -45
- package/dist/lib/drive-sync.js +0 -238
- package/dist/lib/hq/floor.d.ts +0 -87
- package/dist/lib/hq/floor.js +0 -243
- package/dist/lib/session/sync/crdt.d.ts +0 -44
- package/dist/lib/session/sync/crdt.js +0 -119
- package/dist/lib/session/sync/manifest.d.ts +0 -62
- package/dist/lib/session/sync/manifest.js +0 -100
- package/dist/lib/session/sync/provision.d.ts +0 -49
- package/dist/lib/session/sync/provision.js +0 -91
- package/dist/lib/session/sync/r2.d.ts +0 -32
- package/dist/lib/session/sync/r2.js +0 -121
- package/dist/lib/session/sync/sync.d.ts +0 -106
- package/dist/lib/session/sync/sync.js +0 -374
package/dist/lib/teams/agents.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
};
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* 3. else the team pool has many devices → least-loaded pick
|
|
12
12
|
* 4. else (no pin, no pool) → null == run local
|
|
13
13
|
*
|
|
14
|
+
* Step 3 is cap-aware: a device at its `agents.max-concurrent` cap (from the
|
|
15
|
+
* device doc, passed in via PlacementOptions) is excluded from the auto-pick,
|
|
16
|
+
* and an all-capped pool fails loud. Pins and pools of one are the user's own
|
|
17
|
+
* choice and are never second-guessed.
|
|
18
|
+
*
|
|
14
19
|
* A device whose name equals the local machine id is treated as "local" — it
|
|
15
20
|
* resolves to a null placement so the existing local spawn path runs unchanged,
|
|
16
21
|
* letting the local machine participate in a pool as just another member.
|
|
@@ -20,31 +25,70 @@ import { machineId } from '../session/sync/config.js';
|
|
|
20
25
|
function isLocalDevice(device) {
|
|
21
26
|
return device.toLowerCase() === machineId();
|
|
22
27
|
}
|
|
28
|
+
/** Count RUNNING teammates per pool device. Pure. A null/empty hostName is a
|
|
29
|
+
* LOCAL teammate — it counts against the pool member that is this machine,
|
|
30
|
+
* otherwise a cap on the local device could never engage. */
|
|
31
|
+
function loadByDevice(devices, roster) {
|
|
32
|
+
const load = new Map();
|
|
33
|
+
for (const d of devices)
|
|
34
|
+
load.set(d, 0);
|
|
35
|
+
for (const r of roster) {
|
|
36
|
+
if (r.status !== 'running')
|
|
37
|
+
continue;
|
|
38
|
+
const host = r.hostName ? r.hostName : devices.find((d) => isLocalDevice(d));
|
|
39
|
+
if (!host)
|
|
40
|
+
continue; // local teammate but this machine is not in the pool
|
|
41
|
+
if (load.has(host))
|
|
42
|
+
load.set(host, (load.get(host) ?? 0) + 1);
|
|
43
|
+
}
|
|
44
|
+
return load;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Pool devices excluded from auto-pick because they are at (or over) their
|
|
48
|
+
* `agents.max-concurrent` cap. Returned with the live counts so the caller can
|
|
49
|
+
* state the reason to the user instead of the device silently never winning.
|
|
50
|
+
*/
|
|
51
|
+
export function cappedDevices(devices, roster, maxConcurrent) {
|
|
52
|
+
const load = loadByDevice(devices, roster);
|
|
53
|
+
const capped = [];
|
|
54
|
+
for (const d of devices) {
|
|
55
|
+
const cap = maxConcurrent[d];
|
|
56
|
+
if (cap === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
const running = load.get(d) ?? 0;
|
|
59
|
+
if (running >= cap)
|
|
60
|
+
capped.push({ device: d, running, cap });
|
|
61
|
+
}
|
|
62
|
+
return capped;
|
|
63
|
+
}
|
|
23
64
|
/**
|
|
24
65
|
* Pick the least-loaded device from the pool — the one with the fewest RUNNING
|
|
25
66
|
* teammates currently assigned to it. Ties break by pool order (first wins), so
|
|
26
67
|
* an empty pool fills round-robin-ish as teammates launch. Pure: counts the
|
|
27
68
|
* roster, no I/O.
|
|
69
|
+
*
|
|
70
|
+
* With `maxConcurrent`, devices at their cap are excluded; if EVERY device is
|
|
71
|
+
* capped this throws naming each cap and the fix — a loud failure beats a
|
|
72
|
+
* teammate silently landing on a machine its operator capped.
|
|
28
73
|
*/
|
|
29
|
-
export function pickLeastLoaded(devices, roster) {
|
|
74
|
+
export function pickLeastLoaded(devices, roster, maxConcurrent) {
|
|
30
75
|
if (devices.length === 0) {
|
|
31
76
|
throw new Error('pickLeastLoaded called with an empty device pool');
|
|
32
77
|
}
|
|
33
|
-
const load =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
load.set(r.hostName, (load.get(r.hostName) ?? 0) + 1);
|
|
78
|
+
const load = loadByDevice(devices, roster);
|
|
79
|
+
const capped = new Set(maxConcurrent ? cappedDevices(devices, roster, maxConcurrent).map((c) => c.device) : []);
|
|
80
|
+
const eligible = devices.filter((d) => !capped.has(d));
|
|
81
|
+
if (eligible.length === 0) {
|
|
82
|
+
const detail = devices
|
|
83
|
+
.map((d) => `${d} (${load.get(d) ?? 0}/${maxConcurrent[d]})`)
|
|
84
|
+
.join(', ');
|
|
85
|
+
throw new Error(`Every device in the pool is at its agents.max-concurrent cap: ${detail}. ` +
|
|
86
|
+
`Raise a cap with 'agents devices configure <name> --max-agents N' or add a device to the pool.`);
|
|
43
87
|
}
|
|
44
88
|
// Iterate the pool in declared order so the first device wins ties.
|
|
45
|
-
let best =
|
|
89
|
+
let best = eligible[0];
|
|
46
90
|
let bestLoad = load.get(best) ?? 0;
|
|
47
|
-
for (const d of
|
|
91
|
+
for (const d of eligible) {
|
|
48
92
|
const l = load.get(d) ?? 0;
|
|
49
93
|
if (l < bestLoad) {
|
|
50
94
|
best = d;
|
|
@@ -59,7 +103,7 @@ export function pickLeastLoaded(devices, roster) {
|
|
|
59
103
|
* `{ device: <name> }` for a remote placement. See the cascade in the module
|
|
60
104
|
* header.
|
|
61
105
|
*/
|
|
62
|
-
export function resolvePlacement(team, explicitDevice, roster) {
|
|
106
|
+
export function resolvePlacement(team, explicitDevice, roster, opts) {
|
|
63
107
|
// 1. Explicit pin wins — even without a pool.
|
|
64
108
|
if (explicitDevice) {
|
|
65
109
|
return { device: isLocalDevice(explicitDevice) ? null : explicitDevice };
|
|
@@ -72,7 +116,7 @@ export function resolvePlacement(team, explicitDevice, roster) {
|
|
|
72
116
|
if (pool.length === 1) {
|
|
73
117
|
return { device: isLocalDevice(pool[0]) ? null : pool[0] };
|
|
74
118
|
}
|
|
75
|
-
// 3. Many → least-loaded across the pool.
|
|
76
|
-
const picked = pickLeastLoaded(pool, roster);
|
|
119
|
+
// 3. Many → least-loaded across the pool (cap-aware when caps are provided).
|
|
120
|
+
const picked = pickLeastLoaded(pool, roster, opts?.maxConcurrent);
|
|
77
121
|
return { device: isLocalDevice(picked) ? null : picked };
|
|
78
122
|
}
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -62,7 +62,7 @@ export interface BudgetConfig {
|
|
|
62
62
|
require_confirm_over?: number;
|
|
63
63
|
}
|
|
64
64
|
/** Preview features that users can opt into via `agents beta`. */
|
|
65
|
-
export type BetaFeatureName = '
|
|
65
|
+
export type BetaFeatureName = 'factory' | 'projects';
|
|
66
66
|
/** Subset of chalk color names used for agent-specific terminal output. */
|
|
67
67
|
export type ChalkColor = 'magenta' | 'green' | 'blue' | 'cyan' | 'yellowBright' | 'redBright' | 'whiteBright' | 'blueBright' | 'greenBright' | 'magentaBright' | 'cyanBright';
|
|
68
68
|
/** Static configuration for a single agent -- paths, capabilities, and format conventions. */
|
|
@@ -845,6 +845,22 @@ export interface Meta {
|
|
|
845
845
|
* `agents browser profiles set-default <name>`.
|
|
846
846
|
*/
|
|
847
847
|
defaultBrowserProfile?: string;
|
|
848
|
+
/**
|
|
849
|
+
* User-scope config block (`config:` in central agents.yaml). Holds the
|
|
850
|
+
* user-scope keys from the device-config registry (`lib/device-config.ts`) —
|
|
851
|
+
* today just `interactiveHost`. Syncs fleet-wide via `agents repo push/pull`.
|
|
852
|
+
* Device-scope keys live in {@link Meta.deviceConfig} instead.
|
|
853
|
+
*/
|
|
854
|
+
config?: Record<string, unknown>;
|
|
855
|
+
/**
|
|
856
|
+
* Device-scope config block, carried in memory under a distinct field so it
|
|
857
|
+
* can never leak into the central (synced) agents.yaml: `writeMetaUnlocked`
|
|
858
|
+
* routes it to `~/.agents/devices/<machine>/agents.yaml` under the `config:`
|
|
859
|
+
* key (mirroring how `defaultBrowserProfile` is routed), and
|
|
860
|
+
* `overlayMachineLocal` reads it back. Holds the device-scope keys from the
|
|
861
|
+
* device-config registry (`maxAgents`, `schedulerEnabled`, `notes`). Per-machine by design — unset = today's behavior.
|
|
862
|
+
*/
|
|
863
|
+
deviceConfig?: Record<string, unknown>;
|
|
848
864
|
/**
|
|
849
865
|
* Agent-host registry keyed by host name (`agents hosts`). Portable user
|
|
850
866
|
* config synced with `agents repo push/pull`. For `ssh-config` hosts this is
|
|
@@ -873,10 +889,11 @@ export interface Meta {
|
|
|
873
889
|
};
|
|
874
890
|
/**
|
|
875
891
|
* Owner/channel notification config for `agents send` / `agents notify`.
|
|
876
|
-
* `owner` is the
|
|
877
|
-
* `transports` maps a user-facing channel name to the
|
|
878
|
-
* delivers it — explicit, one provider per channel,
|
|
879
|
-
* default to name-identity (channel `slack` ->
|
|
892
|
+
* `owner` is the address expanded by `--to owner` and by `agents notify`
|
|
893
|
+
* (channel + target). `transports` maps a user-facing channel name to the
|
|
894
|
+
* provider that actually delivers it — explicit, one provider per channel,
|
|
895
|
+
* no fallback. Omitted keys default to name-identity (channel `slack` ->
|
|
896
|
+
* provider `slack`).
|
|
880
897
|
*/
|
|
881
898
|
notify?: {
|
|
882
899
|
owner?: {
|
package/dist/lib/versions.d.ts
CHANGED
|
@@ -464,6 +464,12 @@ export interface SyncResult {
|
|
|
464
464
|
subagents: string[];
|
|
465
465
|
plugins: string[];
|
|
466
466
|
workflows: string[];
|
|
467
|
+
/**
|
|
468
|
+
* Project files the sync left alone because the workspace already has them
|
|
469
|
+
* (repo-relative). Reported once, grouped, by the command that rendered the
|
|
470
|
+
* sync — never one line per file from down here.
|
|
471
|
+
*/
|
|
472
|
+
projectSkipped: string[];
|
|
467
473
|
}
|
|
468
474
|
/**
|
|
469
475
|
* Enumerate the DotAgent repo names that resources can be scoped to:
|
package/dist/lib/versions.js
CHANGED
|
@@ -2420,7 +2420,7 @@ export function mergeRepoScopedSelections(repos, cwd = process.cwd()) {
|
|
|
2420
2420
|
*/
|
|
2421
2421
|
export function syncResourcesToVersion(agent, version, selection, options = {}) {
|
|
2422
2422
|
if (isAgentHardDeprecated(agent)) {
|
|
2423
|
-
return { commands: false, skills: false, hooks: false, memory: [], permissions: false, mcp: [], subagents: [], plugins: [], workflows: [] };
|
|
2423
|
+
return { commands: false, skills: false, hooks: false, memory: [], permissions: false, mcp: [], subagents: [], plugins: [], workflows: [], projectSkipped: [] };
|
|
2424
2424
|
}
|
|
2425
2425
|
const agentConfig = AGENTS[agent];
|
|
2426
2426
|
const versionHome = getVersionHomePath(agent, version);
|
|
@@ -2431,7 +2431,7 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
|
|
|
2431
2431
|
// care about the ORIGINAL intent: a caller passing no selection means
|
|
2432
2432
|
// "full sync; persist the staleness manifest after."
|
|
2433
2433
|
const userPassedSelection = selection !== undefined;
|
|
2434
|
-
const result = { commands: false, skills: false, hooks: false, memory: [], permissions: false, mcp: [], subagents: [], plugins: [], workflows: [] };
|
|
2434
|
+
const result = { commands: false, skills: false, hooks: false, memory: [], permissions: false, mcp: [], subagents: [], plugins: [], workflows: [], projectSkipped: [] };
|
|
2435
2435
|
const cwd = options.cwd || process.cwd();
|
|
2436
2436
|
const projectAgentsDir = options.projectDir || getProjectAgentsDir(cwd);
|
|
2437
2437
|
const userAgentsDir = getUserAgentsDir();
|
|
@@ -2499,7 +2499,7 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
|
|
|
2499
2499
|
}
|
|
2500
2500
|
}
|
|
2501
2501
|
if (projectAgentsDir) {
|
|
2502
|
-
syncProjectResourcesToAgent(agent, version, projectAgentsDir);
|
|
2502
|
+
result.projectSkipped = syncProjectResourcesToAgent(agent, version, projectAgentsDir).skipped;
|
|
2503
2503
|
}
|
|
2504
2504
|
// Fast guard: skip the entire sync when the caller requested a full sync and
|
|
2505
2505
|
// nothing has changed since the last full sync. Pattern-derived selections
|
|
@@ -2508,7 +2508,9 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
|
|
|
2508
2508
|
if (!userPassedSelection && !options.force) {
|
|
2509
2509
|
const manifest = loadManifest(agent, version);
|
|
2510
2510
|
if (manifest && !isStale(manifest, agent, version, cwd)) {
|
|
2511
|
-
|
|
2511
|
+
// Nothing synced, but the project sync above already ran — carry its
|
|
2512
|
+
// skipped files out so the caller can still report them.
|
|
2513
|
+
return { ...result };
|
|
2512
2514
|
}
|
|
2513
2515
|
}
|
|
2514
2516
|
// Helper: remove a path (symlink or real) if it exists
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -90,7 +90,6 @@
|
|
|
90
90
|
"@xterm/headless": "6.0.0",
|
|
91
91
|
"@zed-industries/agent-client-protocol": "0.4.5",
|
|
92
92
|
"age-encryption": "0.3.0",
|
|
93
|
-
"aws4fetch": "1.0.20",
|
|
94
93
|
"chalk": "5.6.2",
|
|
95
94
|
"commander": "15.0.0",
|
|
96
95
|
"croner": "10.0.1",
|
package/dist/commands/drive.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Drive sync commands.
|
|
3
|
-
*
|
|
4
|
-
* Registers the `agents drive` command tree for syncing agent session
|
|
5
|
-
* history across machines via rsync. Supports pull, push, attach
|
|
6
|
-
* (redirect agent homes to drive), and detach operations.
|
|
7
|
-
*/
|
|
8
|
-
import type { Command } from 'commander';
|
|
9
|
-
/** Register the `agents drive` command tree. */
|
|
10
|
-
export declare function registerDriveCommands(program: Command): void;
|
package/dist/commands/drive.js
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Drive sync commands.
|
|
3
|
-
*
|
|
4
|
-
* Registers the `agents drive` command tree for syncing agent session
|
|
5
|
-
* history across machines via rsync. Supports pull, push, attach
|
|
6
|
-
* (redirect agent homes to drive), and detach operations.
|
|
7
|
-
*/
|
|
8
|
-
import chalk from 'chalk';
|
|
9
|
-
import ora from 'ora';
|
|
10
|
-
import { betaEnableHint, isBetaEnabled } from '../lib/beta.js';
|
|
11
|
-
import { setRemote, pull, push, attach, detach, getDriveStatus, } from '../lib/drive-sync.js';
|
|
12
|
-
/** Register the `agents drive` command tree. */
|
|
13
|
-
export function registerDriveCommands(program) {
|
|
14
|
-
const enabled = isBetaEnabled('drive');
|
|
15
|
-
const driveCmd = program
|
|
16
|
-
.command('drive', { hidden: !enabled })
|
|
17
|
-
.description('Sync agent session history across machines via rsync. Set up once, then pull/push to keep sessions in sync.')
|
|
18
|
-
.addHelpText('after', `
|
|
19
|
-
Typical workflow:
|
|
20
|
-
# One-time setup: point to your remote machine
|
|
21
|
-
agents drive remote user@hostname
|
|
22
|
-
|
|
23
|
-
# Pull sessions from remote to local
|
|
24
|
-
agents drive pull
|
|
25
|
-
|
|
26
|
-
# Work locally with your agents...
|
|
27
|
-
|
|
28
|
-
# Push your new sessions back to remote
|
|
29
|
-
agents drive push
|
|
30
|
-
|
|
31
|
-
# Check sync state
|
|
32
|
-
agents drive status
|
|
33
|
-
|
|
34
|
-
Use case: Keep session history consistent across your laptop and desktop,
|
|
35
|
-
or back up sessions to a server you control.
|
|
36
|
-
`);
|
|
37
|
-
driveCmd.hook('preAction', () => {
|
|
38
|
-
if (enabled)
|
|
39
|
-
return;
|
|
40
|
-
console.error(chalk.red('agents drive is in beta.'));
|
|
41
|
-
console.error(chalk.gray(betaEnableHint('drive')));
|
|
42
|
-
process.exit(1);
|
|
43
|
-
});
|
|
44
|
-
driveCmd
|
|
45
|
-
.command('remote <target>')
|
|
46
|
-
.description('Set the rsync remote target (e.g., user@hostname). Sessions sync to <target>:~/.agents/drive/')
|
|
47
|
-
.addHelpText('after', `
|
|
48
|
-
Examples:
|
|
49
|
-
# Set remote to a server
|
|
50
|
-
agents drive remote user@server
|
|
51
|
-
|
|
52
|
-
# Set remote to a local machine on your network
|
|
53
|
-
agents drive remote macbook.local
|
|
54
|
-
`)
|
|
55
|
-
.action((target) => {
|
|
56
|
-
try {
|
|
57
|
-
setRemote(target);
|
|
58
|
-
console.log(chalk.green(`Remote set to ${target}`));
|
|
59
|
-
console.log(chalk.gray(`Syncs to ${target}:~/.agents/drive/`));
|
|
60
|
-
}
|
|
61
|
-
catch (err) {
|
|
62
|
-
console.error(chalk.red(err.message));
|
|
63
|
-
process.exit(1);
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
driveCmd
|
|
67
|
-
.command('pull')
|
|
68
|
-
.description('Fetch sessions from the remote and merge them into your local ~/.agents/drive/.')
|
|
69
|
-
.addHelpText('after', `
|
|
70
|
-
Example:
|
|
71
|
-
agents drive pull
|
|
72
|
-
|
|
73
|
-
Run this when you switch machines to get the latest session history.
|
|
74
|
-
`)
|
|
75
|
-
.action(async () => {
|
|
76
|
-
const spinner = ora('Pulling from remote...').start();
|
|
77
|
-
try {
|
|
78
|
-
await pull();
|
|
79
|
-
spinner.succeed('Pull complete');
|
|
80
|
-
}
|
|
81
|
-
catch (err) {
|
|
82
|
-
spinner.fail(err.message);
|
|
83
|
-
process.exit(1);
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
driveCmd
|
|
87
|
-
.command('push')
|
|
88
|
-
.description('Upload your local sessions to the remote so other machines can pull them.')
|
|
89
|
-
.addHelpText('after', `
|
|
90
|
-
Example:
|
|
91
|
-
agents drive push
|
|
92
|
-
|
|
93
|
-
Run this after working locally to share your new sessions with other machines.
|
|
94
|
-
`)
|
|
95
|
-
.action(async () => {
|
|
96
|
-
const spinner = ora('Pushing to remote...').start();
|
|
97
|
-
try {
|
|
98
|
-
await push();
|
|
99
|
-
spinner.succeed('Push complete');
|
|
100
|
-
}
|
|
101
|
-
catch (err) {
|
|
102
|
-
spinner.fail(err.message);
|
|
103
|
-
process.exit(1);
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
driveCmd
|
|
107
|
-
.command('attach')
|
|
108
|
-
.description('Point your agent homes (~/.claude, etc.) at drive so sessions write directly to the synced location.')
|
|
109
|
-
.addHelpText('after', `
|
|
110
|
-
Example:
|
|
111
|
-
agents drive attach
|
|
112
|
-
|
|
113
|
-
After attach, agent sessions are saved to ~/.agents/drive/ instead of version homes.
|
|
114
|
-
Use 'detach' to switch back to version-specific homes.
|
|
115
|
-
`)
|
|
116
|
-
.action(() => {
|
|
117
|
-
try {
|
|
118
|
-
attach();
|
|
119
|
-
console.log(chalk.green('Drive attached'));
|
|
120
|
-
}
|
|
121
|
-
catch (err) {
|
|
122
|
-
console.error(chalk.red(err.message));
|
|
123
|
-
process.exit(1);
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
driveCmd
|
|
127
|
-
.command('detach')
|
|
128
|
-
.description('Restore agent homes back to their version-specific directories (undo attach).')
|
|
129
|
-
.addHelpText('after', `
|
|
130
|
-
Example:
|
|
131
|
-
agents drive detach
|
|
132
|
-
`)
|
|
133
|
-
.action(() => {
|
|
134
|
-
try {
|
|
135
|
-
detach();
|
|
136
|
-
console.log(chalk.green('Drive detached'));
|
|
137
|
-
}
|
|
138
|
-
catch (err) {
|
|
139
|
-
console.error(chalk.red(err.message));
|
|
140
|
-
process.exit(1);
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
driveCmd
|
|
144
|
-
.command('status')
|
|
145
|
-
.description('Show current drive configuration, attach state, and last sync times.')
|
|
146
|
-
.addHelpText('after', `
|
|
147
|
-
Example:
|
|
148
|
-
agents drive status
|
|
149
|
-
`)
|
|
150
|
-
.action(() => {
|
|
151
|
-
const status = getDriveStatus();
|
|
152
|
-
console.log(chalk.bold('Drive'));
|
|
153
|
-
console.log(` Remote: ${status.remote || chalk.gray('not set')}`);
|
|
154
|
-
console.log(` Attached: ${status.attached ? chalk.green('yes') : chalk.gray('no')}`);
|
|
155
|
-
console.log(` Last pull: ${status.lastPull ? formatTime(status.lastPull) : chalk.gray('never')}`);
|
|
156
|
-
console.log(` Last push: ${status.lastPush ? formatTime(status.lastPush) : chalk.gray('never')}`);
|
|
157
|
-
console.log(` Path: ${chalk.gray(status.driveDir)}`);
|
|
158
|
-
console.log('');
|
|
159
|
-
console.log(chalk.bold('Symlinks'));
|
|
160
|
-
console.log(` ~/.claude -> ${status.configDirTarget || chalk.gray('not a symlink')}`);
|
|
161
|
-
for (const [hf, target] of Object.entries(status.homeFileTargets)) {
|
|
162
|
-
console.log(` ~/${hf} -> ${target || chalk.gray('not a symlink')}`);
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
/** Format an ISO timestamp as a human-readable relative time (e.g. "5 min ago"). */
|
|
167
|
-
function formatTime(iso) {
|
|
168
|
-
const d = new Date(iso);
|
|
169
|
-
const now = Date.now();
|
|
170
|
-
const diffMin = Math.floor((now - d.getTime()) / 60_000);
|
|
171
|
-
if (diffMin < 1)
|
|
172
|
-
return 'just now';
|
|
173
|
-
if (diffMin < 60)
|
|
174
|
-
return `${diffMin} min ago`;
|
|
175
|
-
const diffHrs = Math.floor(diffMin / 60);
|
|
176
|
-
if (diffHrs < 24)
|
|
177
|
-
return `${diffHrs} hour${diffHrs === 1 ? '' : 's'} ago`;
|
|
178
|
-
const diffDays = Math.floor(diffHrs / 24);
|
|
179
|
-
if (diffDays < 7)
|
|
180
|
-
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
|
181
|
-
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
182
|
-
return `${months[d.getMonth()]} ${d.getDate()}`;
|
|
183
|
-
}
|
package/dist/commands/hq.d.ts
DELETED