@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.
- package/CHANGELOG.md +290 -0
- package/README.md +4 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/beta.js +3 -5
- package/dist/commands/doctor.js +23 -0
- package/dist/commands/exec.js +13 -0
- package/dist/commands/feed.js +37 -23
- package/dist/commands/mailboxes.js +39 -1
- package/dist/commands/message.js +12 -1
- package/dist/commands/monitors.js +8 -6
- package/dist/commands/packages.js +14 -5
- package/dist/commands/projects.d.ts +28 -0
- package/dist/commands/projects.js +173 -47
- package/dist/commands/routines.js +27 -1
- package/dist/commands/rules.js +6 -0
- 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 +4 -9
- package/dist/commands/watchdog.js +26 -3
- package/dist/index.js +22 -6
- package/dist/lib/activity.d.ts +13 -0
- package/dist/lib/activity.js +22 -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/providers/openclaw-telegram.js +1 -1
- package/dist/lib/channels/resolve.d.ts +18 -1
- package/dist/lib/channels/resolve.js +15 -5
- 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.d.ts +2 -2
- package/dist/lib/events.js +4 -53
- package/dist/lib/feed-broadcast.d.ts +41 -5
- package/dist/lib/feed-broadcast.js +110 -12
- package/dist/lib/feed-post.d.ts +16 -2
- package/dist/lib/feed-post.js +35 -7
- package/dist/lib/feed.d.ts +1 -1
- package/dist/lib/feed.js +3 -1
- package/dist/lib/git.d.ts +13 -2
- package/dist/lib/git.js +38 -6
- package/dist/lib/hooks.d.ts +16 -1
- package/dist/lib/hooks.js +61 -1
- package/dist/lib/linear-project-counts.d.ts +72 -5
- package/dist/lib/linear-project-counts.js +90 -6
- package/dist/lib/linear-projects.d.ts +12 -0
- package/dist/lib/linear-projects.js +30 -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/migrate.js +1 -1
- package/dist/lib/models.js +21 -11
- package/dist/lib/monitors/config.d.ts +1 -1
- package/dist/lib/monitors/dispatch.d.ts +6 -2
- package/dist/lib/monitors/dispatch.js +10 -15
- package/dist/lib/notify.d.ts +42 -6
- package/dist/lib/notify.js +41 -32
- 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/remote-agents-json.d.ts +14 -1
- package/dist/lib/remote-agents-json.js +21 -3
- 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/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/index.d.ts +4 -5
- package/dist/lib/secrets/index.js +4 -5
- 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-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/startup/command-registry.d.ts +0 -2
- package/dist/lib/startup/command-registry.js +1 -5
- package/dist/lib/state.d.ts +7 -2
- package/dist/lib/state.js +85 -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 +23 -1
- 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
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -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:
|
package/dist/lib/routines.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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;
|