@phnx-labs/agents-cli 1.22.13 → 1.22.15

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 (48) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +4 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.js +4 -6
  5. package/dist/commands/packages.js +90 -22
  6. package/dist/commands/projects.d.ts +2 -5
  7. package/dist/commands/projects.js +92 -65
  8. package/dist/commands/routines.js +53 -71
  9. package/dist/commands/secrets.js +10 -6
  10. package/dist/commands/sessions-optimize.d.ts +14 -0
  11. package/dist/commands/sessions-optimize.js +41 -0
  12. package/dist/commands/sessions.js +2 -0
  13. package/dist/commands/setup-watchdog.d.ts +3 -0
  14. package/dist/commands/setup-watchdog.js +54 -0
  15. package/dist/commands/setup.js +8 -1
  16. package/dist/commands/ssh.d.ts +11 -0
  17. package/dist/commands/ssh.js +18 -4
  18. package/dist/commands/watchdog.d.ts +3 -4
  19. package/dist/commands/watchdog.js +40 -28
  20. package/dist/lib/auto-dispatch.d.ts +8 -10
  21. package/dist/lib/auto-dispatch.js +20 -42
  22. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  23. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  24. package/dist/lib/migrate.d.ts +1 -1
  25. package/dist/lib/migrate.js +4 -3
  26. package/dist/lib/project-import.d.ts +7 -46
  27. package/dist/lib/project-import.js +9 -108
  28. package/dist/lib/projects.d.ts +17 -0
  29. package/dist/lib/projects.js +17 -1
  30. package/dist/lib/registry.d.ts +1 -1
  31. package/dist/lib/registry.js +11 -0
  32. package/dist/lib/routine-activation.d.ts +14 -0
  33. package/dist/lib/routine-activation.js +78 -0
  34. package/dist/lib/routines-project.d.ts +1 -1
  35. package/dist/lib/routines-project.js +3 -9
  36. package/dist/lib/routines.d.ts +3 -1
  37. package/dist/lib/routines.js +32 -19
  38. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  39. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  40. package/dist/lib/session/db.d.ts +19 -0
  41. package/dist/lib/session/db.js +24 -0
  42. package/dist/lib/state.js +17 -4
  43. package/dist/lib/types.d.ts +14 -1
  44. package/dist/lib/watchdog/rotate.d.ts +1 -1
  45. package/dist/lib/watchdog/rotate.js +1 -1
  46. package/package.json +1 -1
  47. package/dist/lib/watchdog/routine.d.ts +0 -44
  48. package/dist/lib/watchdog/routine.js +0 -69
@@ -0,0 +1,14 @@
1
+ export declare function normalizeRoutineNames(names: Iterable<string>): string[];
2
+ /** null means this device has not materialized activation state yet. */
3
+ export declare function enabledRoutineNames(): string[] | null;
4
+ export declare function routineEnabledOnThisDevice(name: string): boolean | null;
5
+ export declare function replaceEnabledRoutines(names: Iterable<string>): string[];
6
+ /**
7
+ * Add or remove one routine on this machine. `legacyEnabledNames` seeds the
8
+ * manifest the first time an upgraded host changes activation, preserving every
9
+ * other routine that was effectively enabled under the old definition fields.
10
+ */
11
+ export declare function setRoutineEnabledOnThisDevice(name: string, enabled: boolean, legacyEnabledNames?: Iterable<string>): string[];
12
+ /** Read-only fleet view from synced device documents. Never writes a peer file. */
13
+ export declare function devicesWithRoutineEnabled(name: string): string[];
14
+ export declare function currentRoutineDevice(): string;
@@ -0,0 +1,78 @@
1
+ /** Device-scoped routine activation.
2
+ *
3
+ * Definitions say what a routine does. This module owns the independent answer
4
+ * to whether THIS device runs it: membership in the top-level `routines:` list
5
+ * of `~/.agents/devices/<machine>/agents.yaml`.
6
+ */
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import * as yaml from 'yaml';
10
+ import { machineId } from './machine-id.js';
11
+ import { getUserAgentsDir, readMeta, updateMeta } from './state.js';
12
+ export function normalizeRoutineNames(names) {
13
+ return [...new Set([...names].map((name) => name.trim()).filter(Boolean))].sort();
14
+ }
15
+ /** null means this device has not materialized activation state yet. */
16
+ export function enabledRoutineNames() {
17
+ const names = readMeta().deviceRoutines;
18
+ return Array.isArray(names) ? normalizeRoutineNames(names) : null;
19
+ }
20
+ export function routineEnabledOnThisDevice(name) {
21
+ const names = enabledRoutineNames();
22
+ return names === null ? null : names.includes(name);
23
+ }
24
+ export function replaceEnabledRoutines(names) {
25
+ const normalized = normalizeRoutineNames(names);
26
+ updateMeta((meta) => ({ ...meta, deviceRoutines: normalized }));
27
+ return normalized;
28
+ }
29
+ /**
30
+ * Add or remove one routine on this machine. `legacyEnabledNames` seeds the
31
+ * manifest the first time an upgraded host changes activation, preserving every
32
+ * other routine that was effectively enabled under the old definition fields.
33
+ */
34
+ export function setRoutineEnabledOnThisDevice(name, enabled, legacyEnabledNames = []) {
35
+ const current = enabledRoutineNames() ?? normalizeRoutineNames(legacyEnabledNames);
36
+ const next = new Set(current);
37
+ if (enabled)
38
+ next.add(name);
39
+ else
40
+ next.delete(name);
41
+ return replaceEnabledRoutines(next);
42
+ }
43
+ /** Read-only fleet view from synced device documents. Never writes a peer file. */
44
+ export function devicesWithRoutineEnabled(name) {
45
+ const devicesDir = path.join(getUserAgentsDir(), 'devices');
46
+ if (!fs.existsSync(devicesDir))
47
+ return [];
48
+ const devices = [];
49
+ for (const entry of fs.readdirSync(devicesDir, { withFileTypes: true })) {
50
+ if (!entry.isDirectory())
51
+ continue;
52
+ const file = path.join(devicesDir, entry.name, 'agents.yaml');
53
+ if (!fs.existsSync(file))
54
+ continue;
55
+ let parsed;
56
+ try {
57
+ parsed = yaml.parse(fs.readFileSync(file, 'utf-8'));
58
+ }
59
+ catch (err) {
60
+ throw new Error(`Device config corrupted at ${file}: ${err.message}`);
61
+ }
62
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
63
+ throw new Error(`Device config corrupted at ${file}: expected a YAML map.`);
64
+ }
65
+ const routines = parsed.routines;
66
+ if (routines === undefined)
67
+ continue;
68
+ if (!Array.isArray(routines) || routines.some((routine) => typeof routine !== 'string')) {
69
+ throw new Error(`Device config corrupted at ${file}: routines must be a string list.`);
70
+ }
71
+ if (routines.includes(name))
72
+ devices.push(entry.name);
73
+ }
74
+ return devices.sort();
75
+ }
76
+ export function currentRoutineDevice() {
77
+ return machineId();
78
+ }
@@ -67,7 +67,7 @@ export interface SyncProjectResult {
67
67
  * - Overwrites user copies that already carry matching `source.projectPath`
68
68
  * - Never clobbers a hand-authored user routine (no source / different source)
69
69
  * - Removes user copies from this project whose YAML disappeared
70
- * - Auto-pins `devices` for host/fleet/cloud placement when unset
70
+ * - Activates materialized routines on this device without mutating definitions
71
71
  */
72
72
  export declare function syncProjectRoutines(projectRoot: string): SyncProjectResult;
73
73
  export interface SyncAllResult {
@@ -14,9 +14,8 @@ import * as path from 'path';
14
14
  import * as yaml from 'yaml';
15
15
  import { execFileSync } from 'child_process';
16
16
  import { getProjectAgentsDir, getProjectRoutinesDir, readMeta, updateMeta, ensureAgentsDir, } from './state.js';
17
- import { readJob, writeJob, deleteJob, listJobs, validateJob, resolveHostStrategy, placementRequiresFiringPin, } from './routines.js';
17
+ import { readJob, writeJob, setJobEnabled, deleteJob, listJobs, validateJob, } from './routines.js';
18
18
  import { parseOwnerRepoFromRemote } from './registry.js';
19
- import { machineId } from './machine-id.js';
20
19
  import { isSafeSegmentName } from './paths.js';
21
20
  /** Expand `~/…` and resolve to an absolute path. */
22
21
  export function expandProjectPath(p) {
@@ -221,7 +220,7 @@ function readProjectJobFile(filePath) {
221
220
  * - Overwrites user copies that already carry matching `source.projectPath`
222
221
  * - Never clobbers a hand-authored user routine (no source / different source)
223
222
  * - Removes user copies from this project whose YAML disappeared
224
- * - Auto-pins `devices` for host/fleet/cloud placement when unset
223
+ * - Activates materialized routines on this device without mutating definitions
225
224
  */
226
225
  export function syncProjectRoutines(projectRoot) {
227
226
  ensureAgentsDir();
@@ -276,12 +275,6 @@ export function syncProjectRoutines(projectRoot) {
276
275
  if (existing.createdAt)
277
276
  job.createdAt = existing.createdAt;
278
277
  }
279
- // Placement that leaves the firing machine must pin devices to avoid
280
- // every fleet daemon dispatching once.
281
- const strategy = resolveHostStrategy(job);
282
- if (placementRequiresFiringPin(strategy) && (!job.devices || job.devices.length === 0)) {
283
- job.devices = [machineId()];
284
- }
285
278
  // Surface repo for list/display when project has a GitHub origin.
286
279
  if (git.repo && !job.repo)
287
280
  job.repo = git.repo;
@@ -294,6 +287,7 @@ export function syncProjectRoutines(projectRoot) {
294
287
  }
295
288
  try {
296
289
  writeJob(job);
290
+ setJobEnabled(job.name, job.enabled);
297
291
  result.synced.push(file.name);
298
292
  }
299
293
  catch (err) {
@@ -294,7 +294,7 @@ export declare function finalizeRunMeta(meta: RunMeta, status: RunMeta['status']
294
294
  * `yosemite-s0` all agree. Every fire path (cron scheduler, webhook,
295
295
  * catchup/overdue, manual run) gates on this.
296
296
  */
297
- export declare function jobRunsOnThisDevice(config: Pick<JobConfig, 'devices'>): boolean;
297
+ export declare function jobRunsOnThisDevice(config: Pick<JobConfig, 'name' | 'devices'>): boolean;
298
298
  /**
299
299
  * The ONE device that owns a routine — the single daemon allowed to fire it.
300
300
  *
@@ -423,6 +423,8 @@ export declare function serializeJob(output: Record<string, unknown>, existingTe
423
423
  export declare function deleteJob(name: string): boolean;
424
424
  /** Enable or disable a job by name. */
425
425
  export declare function setJobEnabled(name: string, enabled: boolean): void;
426
+ /** Materialize legacy definition state into this device's activation manifest. */
427
+ export declare function migrateLegacyRoutineActivation(): boolean;
426
428
  /** Validate a partial job config, returning a list of human-readable errors. */
427
429
  export declare function validateJob(config: Partial<JobConfig>): string[];
428
430
  /** Validate a job trigger block, returning a list of human-readable errors. */
@@ -17,6 +17,7 @@ import { ALL_AGENT_IDS } from './agents.js';
17
17
  import { machineId, normalizeHost } from './machine-id.js';
18
18
  import { resolveActor } from './actor.js';
19
19
  import { percentile } from './percentile.js';
20
+ import { enabledRoutineNames, replaceEnabledRoutines, routineEnabledOnThisDevice, setRoutineEnabledOnThisDevice, } from './routine-activation.js';
20
21
  export const HOST_STRATEGIES = ['local', 'host', 'fleet', 'cloud'];
21
22
  /** Canonical set of accepted GitHub trigger events — single source for validation. */
22
23
  export const GITHUB_TRIGGER_EVENTS = [
@@ -82,6 +83,9 @@ export function finalizeRunMeta(meta, status, exitCode, opts) {
82
83
  * catchup/overdue, manual run) gates on this.
83
84
  */
84
85
  export function jobRunsOnThisDevice(config) {
86
+ const activated = routineEnabledOnThisDevice(config.name);
87
+ if (activated !== null)
88
+ return activated;
85
89
  const owner = routineOwnerDevice(config);
86
90
  // Unrestricted: no pin means fleet-wide by design (`watchdog`, `check-updates`).
87
91
  if (owner === null)
@@ -260,7 +264,7 @@ export function listJobs(cwd) {
260
264
  jobs.push(job);
261
265
  }
262
266
  }
263
- return jobs;
267
+ return jobs.map(applyDeviceActivation);
264
268
  }
265
269
  /**
266
270
  * Read a single job config by name, checking project > user > system.
@@ -286,12 +290,16 @@ export function readJob(name, cwd) {
286
290
  const job = readJobFromDir(dir, name);
287
291
  if (job) {
288
292
  if (scope === 'project')
289
- return overlayUserRoutineDevices(job, readJobFromDir(userDir, name));
290
- return job;
293
+ return applyDeviceActivation(overlayUserRoutineDevices(job, readJobFromDir(userDir, name)));
294
+ return applyDeviceActivation(job);
291
295
  }
292
296
  }
293
297
  return null;
294
298
  }
299
+ function applyDeviceActivation(job) {
300
+ const activated = routineEnabledOnThisDevice(job.name);
301
+ return activated === null ? job : { ...job, enabled: activated };
302
+ }
295
303
  function readJobFromDir(dir, name) {
296
304
  for (const ext of ['.yml', '.yaml']) {
297
305
  const filePath = safeJoin(dir, name + ext);
@@ -327,6 +335,10 @@ function readJobFile(filePath) {
327
335
  ...JOB_DEFAULTS,
328
336
  ...parsed,
329
337
  name: parsed.name || path.basename(filePath).replace(/\.ya?ml$/, ''),
338
+ // Before a device manifest exists, preserve only explicit legacy state.
339
+ // A new built-in definition with no `enabled:` field stays opt-in until
340
+ // setup materializes this host's routines list.
341
+ enabled: Object.prototype.hasOwnProperty.call(parsed, 'enabled') ? parsed.enabled !== false : false,
330
342
  };
331
343
  }
332
344
  catch {
@@ -368,15 +380,12 @@ export function writeJob(config) {
368
380
  delete output.effort;
369
381
  if (output.timeout === '10m')
370
382
  delete output.timeout;
371
- if (output.enabled === true)
372
- delete output.enabled;
383
+ delete output.enabled;
373
384
  if (output.runOnce === false || output.runOnce === undefined)
374
385
  delete output.runOnce;
375
386
  if (output.catchup === true || output.catchup === undefined)
376
387
  delete output.catchup;
377
- const devArr = output.devices;
378
- if (!devArr || devArr.length === 0)
379
- delete output.devices;
388
+ delete output.devices;
380
389
  let existingText = null;
381
390
  if (ymlExists || yamlExists) {
382
391
  try {
@@ -441,8 +450,20 @@ export function setJobEnabled(name, enabled) {
441
450
  const job = readJob(name);
442
451
  if (!job)
443
452
  throw new Error(`Job '${name}' not found`);
444
- job.enabled = enabled;
445
- writeJob(job);
453
+ const legacyEnabled = enabledRoutineNames() === null
454
+ ? listJobs().filter((candidate) => candidate.enabled && jobRunsOnThisDevice(candidate)).map((candidate) => candidate.name)
455
+ : [];
456
+ setRoutineEnabledOnThisDevice(name, enabled, legacyEnabled);
457
+ }
458
+ /** Materialize legacy definition state into this device's activation manifest. */
459
+ export function migrateLegacyRoutineActivation() {
460
+ if (enabledRoutineNames() !== null)
461
+ return false;
462
+ const jobs = listJobs();
463
+ if (!jobs.some((job) => job.enabled || Array.isArray(job.devices)))
464
+ return false;
465
+ replaceEnabledRoutines(jobs.filter((job) => job.enabled && jobRunsOnThisDevice(job)).map((job) => job.name));
466
+ return true;
446
467
  }
447
468
  /** Validate a partial job config, returning a list of human-readable errors. */
448
469
  export function validateJob(config) {
@@ -606,15 +627,6 @@ export function validateJob(config) {
606
627
  if (config.catchup !== undefined && typeof config.catchup !== 'boolean') {
607
628
  errors.push('catchup must be a boolean (false to skip running a missed fire late)');
608
629
  }
609
- // Off-box placement without a devices pin fires on every fleet daemon and
610
- // each dispatches once (RUSH-1980). Enforce the pin at validation so hand
611
- // edits and devices --clear cannot re-open the hole.
612
- if (placementRequiresFiringPin(strategy)) {
613
- if (!config.devices || config.devices.length === 0) {
614
- errors.push(`hostStrategy: ${strategy} requires devices: [<name>] to pin which daemon may fire ` +
615
- '(otherwise every fleet daemon dispatches once). Pin to one machine, e.g. devices: [this-host]');
616
- }
617
- }
618
630
  return errors;
619
631
  }
620
632
  /** Validate a job trigger block, returning a list of human-readable errors. */
@@ -1155,6 +1167,7 @@ export function installJobFromSource(sourcePath, name) {
1155
1167
  return { success: false, error: errors.join(', ') };
1156
1168
  }
1157
1169
  writeJob(config);
1170
+ setJobEnabled(config.name, config.enabled);
1158
1171
  return { success: true };
1159
1172
  }
1160
1173
  catch (err) {
@@ -120,6 +120,25 @@ export interface QueryOptions {
120
120
  export declare function getDB(): Database.Database;
121
121
  /** Close the cached database connection. */
122
122
  export declare function closeDB(): void;
123
+ export interface FtsOptimizeResult {
124
+ table: string;
125
+ segmentsBefore: number;
126
+ segmentsAfter: number;
127
+ }
128
+ /**
129
+ * Compact the session + tool-call full-text-search indexes.
130
+ *
131
+ * FTS5 appends a new segment on every insert and leaves a tombstone on every
132
+ * delete. The scanner delete+inserts a session's docs on each rescan (and on
133
+ * every extractor-version bump), and FTS5 never merges those segments on its
134
+ * own — so `tool_call_text_data` / `session_text_data` accumulate hundreds of
135
+ * thousands of unmerged segments, gigabytes of index for tens of MB of content,
136
+ * and `agents sessions` queries slow to a crawl. The FTS5 `'optimize'` command
137
+ * merges every segment into one and purges tombstones. Non-destructive: no
138
+ * searchable content is lost. Reclaimed space becomes reusable free pages inside
139
+ * the DB file; run VACUUM (with the daemon stopped) to return it to the OS.
140
+ */
141
+ export declare function optimizeSessionSearchIndex(): FtsOptimizeResult[];
123
142
  /**
124
143
  * Try to claim the right to run the incremental scan. Returns true if this
125
144
  * process should proceed with scanning, false if another live process is
@@ -820,6 +820,30 @@ export function closeDB() {
820
820
  cachedStmts = {};
821
821
  }
822
822
  }
823
+ /**
824
+ * Compact the session + tool-call full-text-search indexes.
825
+ *
826
+ * FTS5 appends a new segment on every insert and leaves a tombstone on every
827
+ * delete. The scanner delete+inserts a session's docs on each rescan (and on
828
+ * every extractor-version bump), and FTS5 never merges those segments on its
829
+ * own — so `tool_call_text_data` / `session_text_data` accumulate hundreds of
830
+ * thousands of unmerged segments, gigabytes of index for tens of MB of content,
831
+ * and `agents sessions` queries slow to a crawl. The FTS5 `'optimize'` command
832
+ * merges every segment into one and purges tombstones. Non-destructive: no
833
+ * searchable content is lost. Reclaimed space becomes reusable free pages inside
834
+ * the DB file; run VACUUM (with the daemon stopped) to return it to the OS.
835
+ */
836
+ export function optimizeSessionSearchIndex() {
837
+ const db = getDB();
838
+ // Hardcoded literals — never interpolate caller input into an identifier.
839
+ const tables = ['tool_call_text', 'session_text'];
840
+ const segments = (table) => db.prepare(`SELECT count(*) AS n FROM ${table}_data`).get().n;
841
+ return tables.map((table) => {
842
+ const segmentsBefore = segments(table);
843
+ db.prepare(`INSERT INTO ${table}(${table}) VALUES('optimize')`).run();
844
+ return { table, segmentsBefore, segmentsAfter: segments(table) };
845
+ });
846
+ }
823
847
  // ---------------------------------------------------------------------------
824
848
  // Scan coordinator — prevents concurrent full scans across processes
825
849
  // ---------------------------------------------------------------------------
package/dist/lib/state.js CHANGED
@@ -812,6 +812,7 @@ const META_KEY_SCOPE = {
812
812
  versions: 'device',
813
813
  defaultBrowserProfile: 'device',
814
814
  deviceConfig: 'device',
815
+ deviceRoutines: 'device',
815
816
  // Central — synced via agents.yaml.
816
817
  run: 'central',
817
818
  model: 'central',
@@ -909,7 +910,7 @@ function serializeCentral(central) {
909
910
  return isEmpty ? META_HEADER : doc.toString({ collectionStyle: 'block' });
910
911
  }
911
912
  function writeMetaUnlocked(meta) {
912
- const { agents, isolatedAgents, versions, defaultBrowserProfile, deviceConfig, ...central } = meta;
913
+ const { agents, isolatedAgents, versions, defaultBrowserProfile, deviceConfig, deviceRoutines, ...central } = meta;
913
914
  // Write the machine-local files FIRST, then strip central — so a crash mid-write
914
915
  // never removes pins/versions from central before they're persisted elsewhere.
915
916
  const devicePath = getDeviceMetaPath();
@@ -923,7 +924,8 @@ function writeMetaUnlocked(meta) {
923
924
  // Device-scope config (`maxAgents`, `schedulerEnabled`, …) is per-machine, so it
924
925
  // rides the device doc under `config:` — never the central doc that syncs.
925
926
  const hasDeviceConfig = !!deviceConfig && Object.keys(deviceConfig).length > 0;
926
- if (hasAgents || hasIsolatedAgents || hasDefaultBrowser || hasDeviceConfig) {
927
+ const hasDeviceRoutines = Array.isArray(deviceRoutines);
928
+ if (hasAgents || hasIsolatedAgents || hasDefaultBrowser || hasDeviceConfig || hasDeviceRoutines) {
927
929
  // Device-local doc carries `agents:` pins, `defaultBrowserProfile:`, and the
928
930
  // device-scope `config:` — all per-machine and must never land in central
929
931
  // agents.yaml (which syncs).
@@ -936,6 +938,8 @@ function writeMetaUnlocked(meta) {
936
938
  deviceDoc.defaultBrowserProfile = defaultBrowserProfile;
937
939
  if (hasDeviceConfig)
938
940
  deviceDoc.config = deviceConfig;
941
+ if (hasDeviceRoutines)
942
+ deviceDoc.routines = deviceRoutines;
939
943
  fs.mkdirSync(path.dirname(devicePath), { recursive: true });
940
944
  writeIfChanged(devicePath, META_HEADER + yaml.stringify(deviceDoc));
941
945
  }
@@ -971,8 +975,12 @@ function writeMetaUnlocked(meta) {
971
975
  function overlayMachineLocal(meta) {
972
976
  const devicePath = getDeviceMetaPath();
973
977
  if (fs.existsSync(devicePath)) {
978
+ let dm = null;
974
979
  try {
975
- const dm = yaml.parse(fs.readFileSync(devicePath, 'utf-8'));
980
+ dm = yaml.parse(fs.readFileSync(devicePath, 'utf-8'));
981
+ }
982
+ catch { /* preserve the existing tolerance for malformed legacy device YAML */ }
983
+ if (dm) {
976
984
  if (dm?.agents)
977
985
  meta.agents = { ...meta.agents, ...dm.agents };
978
986
  if (dm?.isolatedAgents)
@@ -981,8 +989,13 @@ function overlayMachineLocal(meta) {
981
989
  meta.defaultBrowserProfile = dm.defaultBrowserProfile;
982
990
  if (dm?.config)
983
991
  meta.deviceConfig = dm.config;
992
+ if (dm && Object.prototype.hasOwnProperty.call(dm, 'routines')) {
993
+ if (!Array.isArray(dm.routines) || dm.routines.some((name) => typeof name !== 'string')) {
994
+ throw new Error(`Device config corrupted at ${devicePath}: routines must be a string list.`);
995
+ }
996
+ meta.deviceRoutines = dm.routines;
997
+ }
984
998
  }
985
- catch { /* ignore malformed device file */ }
986
999
  }
987
1000
  const vrPath = getVersionResourcesPath();
988
1001
  if (fs.existsSync(vrPath)) {
@@ -511,10 +511,16 @@ export interface RegistrySearchResult {
511
511
  }
512
512
  /** A package that has been resolved from a registry and is ready to install. */
513
513
  export interface ResolvedPackage {
514
- type: 'mcp' | 'skill' | 'git';
514
+ /** `plugin` is Phase 5 packaging: `agents install plugin:<spec>` → plugins install. */
515
+ type: 'mcp' | 'skill' | 'git' | 'plugin';
515
516
  source: string;
516
517
  mcpEntry?: McpServerEntry;
517
518
  skillEntry?: SkillEntry;
519
+ /**
520
+ * Plugin install spec (`name@url`, path, or bare source) when `type === 'plugin'`.
521
+ * Same grammar as `agents plugins install <spec>`.
522
+ */
523
+ pluginSpec?: string;
518
524
  }
519
525
  /** Categories of resources that can be synced into an agent version home. */
520
526
  export type ResourceType = 'commands' | 'skills' | 'hooks' | 'memory' | 'mcp' | 'permissions' | 'subagents' | 'plugins' | 'workflows';
@@ -937,6 +943,13 @@ export interface Meta {
937
943
  * device-config registry (`maxAgents`, `schedulerEnabled`, `notes`). Per-machine by design — unset = today's behavior.
938
944
  */
939
945
  deviceConfig?: Record<string, unknown>;
946
+ /**
947
+ * Routine names enabled on this machine. In memory this stays distinct from
948
+ * portable user config; state.ts writes it as top-level `routines:` in
949
+ * `~/.agents/devices/<machine>/agents.yaml`. Presence in the list is the whole
950
+ * activation state: absent means disabled on this device.
951
+ */
952
+ deviceRoutines?: string[];
940
953
  /**
941
954
  * Agent-host registry keyed by host name (`agents hosts`). Portable user
942
955
  * config synced with `agents repo push/pull`. For `ssh-config` hosts this is
@@ -166,7 +166,7 @@ export declare function isWatchdogRotateEnabled(): boolean;
166
166
  * Persist `watchdog.rotate: on|off`. Called by the `agents watchdog rotate
167
167
  * on|off` subcommand (commands/watchdog.ts) — the rotate-only switch the
168
168
  * Factory migration uses so a user who opted out of autoRotate keeps nudging
169
- * (rather than `agents watchdog disable`, which kills the whole watchdog).
169
+ * (rather than `agents watchdog off`, which disables the whole watchdog here).
170
170
  */
171
171
  export declare function setWatchdogRotateEnabled(on: boolean): void;
172
172
  export interface RotateGateResult {
@@ -291,7 +291,7 @@ export function isWatchdogRotateEnabled() {
291
291
  * Persist `watchdog.rotate: on|off`. Called by the `agents watchdog rotate
292
292
  * on|off` subcommand (commands/watchdog.ts) — the rotate-only switch the
293
293
  * Factory migration uses so a user who opted out of autoRotate keeps nudging
294
- * (rather than `agents watchdog disable`, which kills the whole watchdog).
294
+ * (rather than `agents watchdog off`, which disables the whole watchdog here).
295
295
  */
296
296
  export function setWatchdogRotateEnabled(on) {
297
297
  const meta = readMeta();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.13",
3
+ "version": "1.22.15",
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",
@@ -1,44 +0,0 @@
1
- /**
2
- * The watchdog, expressed as a routine.
3
- *
4
- * The always-on watchdog is not a bespoke subsystem — it is a plain `command`
5
- * routine the daemon's existing scheduler fires: `agents watchdog --nudge`, once
6
- * every couple of minutes. Enabling / disabling the watchdog is enabling /
7
- * disabling that routine, so there is ONE durable, inspectable concept
8
- * (`agents routines list` shows it; the scheduler catches it up if the daemon was
9
- * down) instead of a private sentinel file and a hand-rolled `--watch` loop.
10
- *
11
- * A `command` routine — not `agent`/`workflow` — because the tick is deterministic
12
- * housekeeping: no LLM, no auth, no sandbox. Each fire runs ONE bounded tick (the
13
- * cron is the loop), so the daemon's per-fire model fits without a long-lived
14
- * process.
15
- */
16
- import { type JobConfig } from '../routines.js';
17
- /** The routine name. `agents routines list` / `readJob` key on this. */
18
- export declare const WATCHDOG_ROUTINE_NAME = "watchdog";
19
- /**
20
- * Fire every 2 minutes. Mirrors the extension's 120s tick, and is well inside the
21
- * 5m stall threshold and 20m nudge cooldown, so a stall is noticed promptly
22
- * without the daemon spawning a tick more often than the cooldown can matter.
23
- */
24
- export declare const WATCHDOG_ROUTINE_SCHEDULE = "*/2 * * * *";
25
- /** The plain shell each fire runs: one bounded, deterministic nudge tick. */
26
- export declare const WATCHDOG_ROUTINE_COMMAND = "agents watchdog --nudge";
27
- /**
28
- * Build the watchdog routine config. Pure — touches no disk. `mode`/`effort`/
29
- * `timeout` are required by JobConfig but ignored for `command` routines;
30
- * writeJob() drops the default ('auto'/'10m') values so the on-disk file stays
31
- * to the point (name + schedule + command + enabled).
32
- */
33
- export declare function buildWatchdogRoutine(enabled: boolean): JobConfig;
34
- /** Is the watchdog routine present on disk (user or system layer)? */
35
- export declare function watchdogRoutineExists(): boolean;
36
- /** Is the watchdog routine present AND enabled? */
37
- export declare function isWatchdogRoutineEnabled(): boolean;
38
- /**
39
- * Ensure the watchdog routine exists with the requested enabled state, preserving
40
- * any user edits. Absent → create it; present → only flip `enabled` (never clobber
41
- * a schedule or command the user tuned). Idempotent: calling it twice with the
42
- * same state is a no-op after the first.
43
- */
44
- export declare function ensureWatchdogRoutine(enabled: boolean): void;
@@ -1,69 +0,0 @@
1
- /**
2
- * The watchdog, expressed as a routine.
3
- *
4
- * The always-on watchdog is not a bespoke subsystem — it is a plain `command`
5
- * routine the daemon's existing scheduler fires: `agents watchdog --nudge`, once
6
- * every couple of minutes. Enabling / disabling the watchdog is enabling /
7
- * disabling that routine, so there is ONE durable, inspectable concept
8
- * (`agents routines list` shows it; the scheduler catches it up if the daemon was
9
- * down) instead of a private sentinel file and a hand-rolled `--watch` loop.
10
- *
11
- * A `command` routine — not `agent`/`workflow` — because the tick is deterministic
12
- * housekeeping: no LLM, no auth, no sandbox. Each fire runs ONE bounded tick (the
13
- * cron is the loop), so the daemon's per-fire model fits without a long-lived
14
- * process.
15
- */
16
- import { readJob, writeJob, setJobEnabled } from '../routines.js';
17
- /** The routine name. `agents routines list` / `readJob` key on this. */
18
- export const WATCHDOG_ROUTINE_NAME = 'watchdog';
19
- /**
20
- * Fire every 2 minutes. Mirrors the extension's 120s tick, and is well inside the
21
- * 5m stall threshold and 20m nudge cooldown, so a stall is noticed promptly
22
- * without the daemon spawning a tick more often than the cooldown can matter.
23
- */
24
- export const WATCHDOG_ROUTINE_SCHEDULE = '*/2 * * * *';
25
- /** The plain shell each fire runs: one bounded, deterministic nudge tick. */
26
- export const WATCHDOG_ROUTINE_COMMAND = 'agents watchdog --nudge';
27
- /**
28
- * Build the watchdog routine config. Pure — touches no disk. `mode`/`effort`/
29
- * `timeout` are required by JobConfig but ignored for `command` routines;
30
- * writeJob() drops the default ('auto'/'10m') values so the on-disk file stays
31
- * to the point (name + schedule + command + enabled).
32
- */
33
- export function buildWatchdogRoutine(enabled) {
34
- return {
35
- name: WATCHDOG_ROUTINE_NAME,
36
- schedule: WATCHDOG_ROUTINE_SCHEDULE,
37
- command: WATCHDOG_ROUTINE_COMMAND,
38
- enabled,
39
- mode: 'auto',
40
- effort: 'auto',
41
- timeout: '10m',
42
- prompt: '',
43
- };
44
- }
45
- /** Is the watchdog routine present on disk (user or system layer)? */
46
- export function watchdogRoutineExists() {
47
- return readJob(WATCHDOG_ROUTINE_NAME) !== null;
48
- }
49
- /** Is the watchdog routine present AND enabled? */
50
- export function isWatchdogRoutineEnabled() {
51
- const job = readJob(WATCHDOG_ROUTINE_NAME);
52
- return job !== null && job.enabled === true;
53
- }
54
- /**
55
- * Ensure the watchdog routine exists with the requested enabled state, preserving
56
- * any user edits. Absent → create it; present → only flip `enabled` (never clobber
57
- * a schedule or command the user tuned). Idempotent: calling it twice with the
58
- * same state is a no-op after the first.
59
- */
60
- export function ensureWatchdogRoutine(enabled) {
61
- const existing = readJob(WATCHDOG_ROUTINE_NAME);
62
- if (existing === null) {
63
- writeJob(buildWatchdogRoutine(enabled));
64
- return;
65
- }
66
- if (existing.enabled !== enabled) {
67
- setJobEnabled(WATCHDOG_ROUTINE_NAME, enabled);
68
- }
69
- }