@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
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Pure builders behind `agents projects import`.
3
+ *
4
+ * Two sources feed the one `ProjectDef` schema: **Linear** (the preferred one —
5
+ * a project someone deliberately created on the board) and the **Factory
6
+ * registry** (`~/.agents/factory/projects.json`, an auto-detection that guesses
7
+ * from checkouts on disk). Both funnel through `writeProjectDef`.
8
+ *
9
+ * The Factory registry stamps each row with a `confidence` — and importing every
10
+ * row regardless is what buried the real projects under a dozen guesses
11
+ * (`agents-cleaned-stale2`, a repo cloned from someone else's org, …). So the
12
+ * import is gated on that field, `high` by default.
13
+ *
14
+ * Everything here is a pure function of its arguments — no fs, no shell, no
15
+ * process env. The command layer reads the registry / shells out to `linear` and
16
+ * hands the rows in; that's what makes these testable against plain fixtures
17
+ * with no mocking. The one exception is `toHomeRelative`, a string rewrite
18
+ * against `$HOME`.
19
+ */
20
+ import { type ProjectDef } from './projects.js';
21
+ import { type LinearProjectLite } from './linear-projects.js';
22
+ /** Factory's per-row detection confidence, weakest first. */
23
+ export type ImportConfidence = 'low' | 'medium' | 'high';
24
+ /**
25
+ * The floor an import runs at. `any` is what `--all` means — it takes rows that
26
+ * state no confidence at all, which rank below even `low`; without it "import
27
+ * every row regardless of confidence" would quietly drop the unranked ones.
28
+ */
29
+ export type ImportFloor = ImportConfidence | 'any';
30
+ /** A row the import declined to write, with the reason to print. */
31
+ export interface ImportSkip {
32
+ name: string;
33
+ reason: string;
34
+ }
35
+ /** What an import would write, and what it declined. */
36
+ export interface ImportPlan {
37
+ defs: ProjectDef[];
38
+ skipped: ImportSkip[];
39
+ }
40
+ /** The validated shape of the `import` flags. */
41
+ export interface ImportOptions {
42
+ source: 'factory' | 'linear';
43
+ /** Factory only: the lowest confidence that still imports. */
44
+ minConfidence: ImportFloor;
45
+ force: boolean;
46
+ }
47
+ /** The raw commander flags, before validation. */
48
+ export interface RawImportFlags {
49
+ fromFactory?: boolean;
50
+ fromLinear?: boolean;
51
+ minConfidence?: string;
52
+ all?: boolean;
53
+ force?: boolean;
54
+ }
55
+ /**
56
+ * Validate the flag combination, throwing a user-facing message on the first
57
+ * problem. Every rejection is loud: an unrecognized `--min-confidence` is an
58
+ * error, never a silent fall back to the default floor.
59
+ */
60
+ export declare function validateImportOpts(flags: RawImportFlags): ImportOptions;
61
+ /**
62
+ * Plan the Factory import: same field mapping as before (`path`→`root`,
63
+ * `repoSlug`→`repo`, `linearProjectId`→`linear.projectId`), now gated on the
64
+ * row's `confidence`. A row with no confidence field is a guess with no stated
65
+ * strength, so it ranks below every floor and only `--all` takes it.
66
+ */
67
+ export declare function buildFactoryImportCandidates(rows: unknown[], existing: Map<string, ProjectDef>, opts: Pick<ImportOptions, 'minConfidence' | 'force'>): ImportPlan;
68
+ /**
69
+ * Turn a Linear project name into a definition slug: lowercase, every run of
70
+ * unusable characters collapsed to one `-`, trimmed of leading/trailing
71
+ * punctuation, capped at the 64 chars `isSafeProjectName` allows. Returns `''`
72
+ * when nothing usable survives — the caller skips those loudly.
73
+ */
74
+ export declare function slugifyProjectName(name: string): string;
75
+ /** The local-checkout lookups the Linear builder needs, injected so it stays pure. */
76
+ export interface LinearImportDeps {
77
+ /** Directory names directly under the configured projects root. */
78
+ localDirs: string[];
79
+ /** Home-relative root path for one of `localDirs`. */
80
+ resolveRoot: (dir: string) => string | undefined;
81
+ /** `owner/repo` from that checkout's origin remote, when it has one. */
82
+ resolveOrigin: (dir: string) => string | undefined;
83
+ }
84
+ /**
85
+ * Plan the Linear import. Every project becomes a def carrying its `linear`
86
+ * link; the local checkout is bound **only on an exact normalized-name match**
87
+ * (`matchLocalCheckoutExact`). The containment fallback that powers the `link`
88
+ * suggestion is deliberately not used here — "Agents CLI" containing
89
+ * "agents-cli-web" is a fine hint for a human to confirm, and a silently wrong
90
+ * `root` on a write path.
91
+ *
92
+ * An existing def is preserved field-for-field; only `name` and `linear` are
93
+ * overwritten, so a hand-set `description`/`contexts`/`integrations` survives a
94
+ * re-import.
95
+ */
96
+ export declare function buildLinearImportCandidates(projects: LinearProjectLite[], existing: Map<string, ProjectDef>, deps: LinearImportDeps, opts: Pick<ImportOptions, 'force'>): ImportPlan;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Pure builders behind `agents projects import`.
3
+ *
4
+ * Two sources feed the one `ProjectDef` schema: **Linear** (the preferred one —
5
+ * a project someone deliberately created on the board) and the **Factory
6
+ * registry** (`~/.agents/factory/projects.json`, an auto-detection that guesses
7
+ * from checkouts on disk). Both funnel through `writeProjectDef`.
8
+ *
9
+ * The Factory registry stamps each row with a `confidence` — and importing every
10
+ * row regardless is what buried the real projects under a dozen guesses
11
+ * (`agents-cleaned-stale2`, a repo cloned from someone else's org, …). So the
12
+ * import is gated on that field, `high` by default.
13
+ *
14
+ * Everything here is a pure function of its arguments — no fs, no shell, no
15
+ * process env. The command layer reads the registry / shells out to `linear` and
16
+ * hands the rows in; that's what makes these testable against plain fixtures
17
+ * with no mocking. The one exception is `toHomeRelative`, a string rewrite
18
+ * against `$HOME`.
19
+ */
20
+ import { toHomeRelative } from './project-root.js';
21
+ import { isSafeProjectName } from './projects.js';
22
+ import { matchLocalCheckoutExact } from './linear-projects.js';
23
+ const CONFIDENCE_RANK = { any: 0, low: 1, medium: 2, high: 3 };
24
+ /** Rank a raw `confidence` value; anything absent or unrecognized ranks 0 (below every floor). */
25
+ function confidenceRank(raw) {
26
+ return typeof raw === 'string' ? (CONFIDENCE_RANK[raw] ?? 0) : 0;
27
+ }
28
+ /**
29
+ * Validate the flag combination, throwing a user-facing message on the first
30
+ * problem. Every rejection is loud: an unrecognized `--min-confidence` is an
31
+ * error, never a silent fall back to the default floor.
32
+ */
33
+ export function validateImportOpts(flags) {
34
+ const sources = [flags.fromFactory && 'factory', flags.fromLinear && 'linear'].filter(Boolean);
35
+ if (sources.length === 0)
36
+ throw new Error('Pick an import source: --from-linear or --from-factory.');
37
+ if (sources.length > 1)
38
+ throw new Error('--from-linear and --from-factory are mutually exclusive — pick one.');
39
+ const source = sources[0];
40
+ if (source === 'linear' && (flags.all || flags.minConfidence !== undefined)) {
41
+ throw new Error('--all and --min-confidence apply to --from-factory only (Linear rows carry no confidence).');
42
+ }
43
+ if (flags.all && flags.minConfidence !== undefined) {
44
+ throw new Error('--all and --min-confidence are mutually exclusive (--all means --min-confidence low).');
45
+ }
46
+ let minConfidence = 'high';
47
+ if (flags.all)
48
+ minConfidence = 'any';
49
+ else if (flags.minConfidence !== undefined) {
50
+ const v = flags.minConfidence.trim().toLowerCase();
51
+ if (v !== 'low' && v !== 'medium' && v !== 'high') {
52
+ throw new Error(`Invalid --min-confidence "${flags.minConfidence}" (expected low, medium, or high).`);
53
+ }
54
+ minConfidence = v;
55
+ }
56
+ return { source, minConfidence, force: flags.force === true };
57
+ }
58
+ /**
59
+ * Plan the Factory import: same field mapping as before (`path`→`root`,
60
+ * `repoSlug`→`repo`, `linearProjectId`→`linear.projectId`), now gated on the
61
+ * row's `confidence`. A row with no confidence field is a guess with no stated
62
+ * strength, so it ranks below every floor and only `--all` takes it.
63
+ */
64
+ export function buildFactoryImportCandidates(rows, existing, opts) {
65
+ const floor = CONFIDENCE_RANK[opts.minConfidence];
66
+ const defs = [];
67
+ const skipped = [];
68
+ // The registry keys rows by `owner/repo` but names them by basename, so two
69
+ // repos with the same basename in different orgs (grinich/inflow, me/inflow)
70
+ // arrive as two rows called `inflow`. Without this the second silently
71
+ // overwrote the first on disk and the run still reported both as imported.
72
+ const seen = new Set();
73
+ for (const raw of rows) {
74
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
75
+ continue;
76
+ const o = raw;
77
+ const name = typeof o.name === 'string' ? o.name : undefined;
78
+ if (!name || !isSafeProjectName(name)) {
79
+ skipped.push({ name: name || '(unnamed)', reason: 'not a usable project name' });
80
+ continue;
81
+ }
82
+ const rank = confidenceRank(o.confidence);
83
+ if (rank < floor) {
84
+ const stated = typeof o.confidence === 'string' && o.confidence ? `confidence "${o.confidence}"` : 'no confidence field';
85
+ skipped.push({ name, reason: `${stated} is below the "${opts.minConfidence}" floor` });
86
+ continue;
87
+ }
88
+ if (seen.has(name)) {
89
+ skipped.push({ name, reason: 'another row in this registry already claimed the name' });
90
+ continue;
91
+ }
92
+ if (existing.has(name) && !opts.force) {
93
+ skipped.push({ name, reason: 'already defined — pass --force to overwrite' });
94
+ continue;
95
+ }
96
+ seen.add(name);
97
+ const def = { name };
98
+ if (typeof o.path === 'string')
99
+ def.root = toHomeRelative(o.path);
100
+ if (typeof o.repoSlug === 'string')
101
+ def.repo = o.repoSlug;
102
+ if (typeof o.linearProjectId === 'string')
103
+ def.linear = { projectId: o.linearProjectId };
104
+ defs.push(def);
105
+ }
106
+ return { defs, skipped };
107
+ }
108
+ /**
109
+ * Turn a Linear project name into a definition slug: lowercase, every run of
110
+ * unusable characters collapsed to one `-`, trimmed of leading/trailing
111
+ * punctuation, capped at the 64 chars `isSafeProjectName` allows. Returns `''`
112
+ * when nothing usable survives — the caller skips those loudly.
113
+ */
114
+ export function slugifyProjectName(name) {
115
+ const slug = name
116
+ .toLowerCase()
117
+ .replace(/[^a-z0-9._-]+/g, '-')
118
+ .replace(/-{2,}/g, '-')
119
+ .replace(/^[-._]+|[-._]+$/g, '')
120
+ .slice(0, 64)
121
+ .replace(/[-._]+$/g, '');
122
+ return isSafeProjectName(slug) ? slug : '';
123
+ }
124
+ /**
125
+ * Plan the Linear import. Every project becomes a def carrying its `linear`
126
+ * link; the local checkout is bound **only on an exact normalized-name match**
127
+ * (`matchLocalCheckoutExact`). The containment fallback that powers the `link`
128
+ * suggestion is deliberately not used here — "Agents CLI" containing
129
+ * "agents-cli-web" is a fine hint for a human to confirm, and a silently wrong
130
+ * `root` on a write path.
131
+ *
132
+ * An existing def is preserved field-for-field; only `name` and `linear` are
133
+ * overwritten, so a hand-set `description`/`contexts`/`integrations` survives a
134
+ * re-import.
135
+ */
136
+ export function buildLinearImportCandidates(projects, existing, deps, opts) {
137
+ const defs = [];
138
+ const skipped = [];
139
+ const seen = new Set();
140
+ for (const p of projects) {
141
+ const name = slugifyProjectName(p.name);
142
+ if (!name) {
143
+ skipped.push({ name: p.name, reason: 'no usable project name (letters, digits, ., _, - only)' });
144
+ continue;
145
+ }
146
+ if (seen.has(name)) {
147
+ skipped.push({ name: p.name, reason: `another Linear project already claimed the name "${name}"` });
148
+ continue;
149
+ }
150
+ const prior = existing.get(name);
151
+ if (prior && (prior.root || prior.repo) && !opts.force) {
152
+ skipped.push({ name, reason: 'existing def already has root/repo — pass --force to relink' });
153
+ continue;
154
+ }
155
+ seen.add(name);
156
+ const def = { ...prior, name, linear: { projectId: p.id } };
157
+ if (p.url)
158
+ def.linear.url = p.url;
159
+ const dir = matchLocalCheckoutExact(p.name, deps.localDirs);
160
+ if (dir) {
161
+ const root = deps.resolveRoot(dir);
162
+ if (root)
163
+ def.root = root;
164
+ const repo = deps.resolveOrigin(dir);
165
+ if (repo)
166
+ def.repo = repo;
167
+ }
168
+ defs.push(def);
169
+ }
170
+ return { defs, skipped };
171
+ }
@@ -2,7 +2,7 @@ export interface RemoteAgentsJsonOptions<T> {
2
2
  args: string[];
3
3
  noFanoutEnv: string;
4
4
  hosts?: string[];
5
- parse: (stdout: string, machine: string) => T[];
5
+ parse: (stdout: string, machine: string) => T[] | RemoteAgentsJsonParseResult<T>;
6
6
  /**
7
7
  * Suppress the per-device "unreachable — skipped" stderr line. The skipped
8
8
  * names still come back in {@link RemoteAgentsJsonResult.skipped}, so a caller
@@ -11,12 +11,25 @@ export interface RemoteAgentsJsonOptions<T> {
11
11
  */
12
12
  quiet?: boolean;
13
13
  }
14
+ export interface RemoteAgentsJsonParseResult<T> {
15
+ items: T[];
16
+ valid: boolean;
17
+ }
14
18
  export interface RemoteAgentsJsonResult<T> {
15
19
  items: T[];
16
20
  deviceCount: number;
17
21
  /** Devices that were dialed but answered with an error / no CLI / a timeout. */
18
22
  skipped: string[];
23
+ /** Devices that exited successfully but returned invalid JSON for this command. */
24
+ parseFailed: string[];
25
+ /** Whether automatic target discovery failed before any peer could be dialed. */
26
+ discoveryFailed: boolean;
19
27
  }
28
+ export declare function normalizeRemoteAgentsJsonParse<T>(parsed: T[] | RemoteAgentsJsonParseResult<T>): RemoteAgentsJsonParseResult<T>;
29
+ export declare function parseRemoteAgentsJsonPayload<T>(stdout: string, machine: string, parse: RemoteAgentsJsonOptions<T>['parse']): {
30
+ items: T[];
31
+ parseFailed: boolean;
32
+ };
20
33
  /** Build the command one peer runs, with a guard that prevents recursive fan-out. */
21
34
  export declare function remoteAgentsJsonCommand(args: string[], noFanoutEnv: string, os?: string): string;
22
35
  /** Query explicit hosts, or every registered online peer when hosts is omitted. */
@@ -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;