@phnx-labs/agents-cli 1.20.93 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +3 -0
  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/mailboxes.js +39 -1
  8. package/dist/commands/message.js +12 -1
  9. package/dist/commands/packages.js +14 -5
  10. package/dist/commands/projects.d.ts +28 -0
  11. package/dist/commands/projects.js +173 -47
  12. package/dist/commands/routines.js +27 -1
  13. package/dist/commands/rules.js +6 -0
  14. package/dist/commands/sessions.d.ts +17 -0
  15. package/dist/commands/sessions.js +16 -8
  16. package/dist/commands/setup-browser.js +5 -1
  17. package/dist/commands/setup-fleet.js +5 -0
  18. package/dist/commands/setup-preferences.d.ts +53 -0
  19. package/dist/commands/setup-preferences.js +142 -0
  20. package/dist/commands/setup.js +8 -9
  21. package/dist/commands/ssh.js +220 -10
  22. package/dist/commands/sync.d.ts +2 -2
  23. package/dist/commands/sync.js +4 -9
  24. package/dist/commands/watchdog.js +26 -3
  25. package/dist/index.js +22 -6
  26. package/dist/lib/activity.d.ts +7 -0
  27. package/dist/lib/activity.js +19 -2
  28. package/dist/lib/auto-dispatch.d.ts +6 -1
  29. package/dist/lib/auto-dispatch.js +7 -2
  30. package/dist/lib/beta.d.ts +1 -1
  31. package/dist/lib/beta.js +1 -1
  32. package/dist/lib/daemon.d.ts +13 -0
  33. package/dist/lib/daemon.js +80 -60
  34. package/dist/lib/device-config.d.ts +82 -0
  35. package/dist/lib/device-config.js +296 -0
  36. package/dist/lib/event-provenance.d.ts +19 -0
  37. package/dist/lib/event-provenance.js +48 -0
  38. package/dist/lib/events.d.ts +2 -2
  39. package/dist/lib/events.js +4 -53
  40. package/dist/lib/feed.d.ts +1 -1
  41. package/dist/lib/feed.js +2 -0
  42. package/dist/lib/git.d.ts +13 -2
  43. package/dist/lib/git.js +38 -6
  44. package/dist/lib/linear-project-counts.d.ts +72 -5
  45. package/dist/lib/linear-project-counts.js +90 -6
  46. package/dist/lib/linear-projects.d.ts +12 -0
  47. package/dist/lib/linear-projects.js +30 -0
  48. package/dist/lib/mailbox-gc.js +30 -7
  49. package/dist/lib/mailbox.d.ts +14 -1
  50. package/dist/lib/mailbox.js +35 -3
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  54. package/dist/lib/menubar/install-menubar.js +35 -44
  55. package/dist/lib/migrate.js +1 -1
  56. package/dist/lib/models.js +21 -11
  57. package/dist/lib/overdue.d.ts +1 -1
  58. package/dist/lib/overdue.js +54 -19
  59. package/dist/lib/project-import.d.ts +96 -0
  60. package/dist/lib/project-import.js +171 -0
  61. package/dist/lib/remote-agents-json.d.ts +14 -1
  62. package/dist/lib/remote-agents-json.js +21 -3
  63. package/dist/lib/routines.d.ts +53 -0
  64. package/dist/lib/routines.js +105 -4
  65. package/dist/lib/rules/run-sync.d.ts +18 -0
  66. package/dist/lib/rules/run-sync.js +92 -0
  67. package/dist/lib/scheduler.js +12 -2
  68. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  69. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  70. package/dist/lib/secrets/index.d.ts +4 -5
  71. package/dist/lib/secrets/index.js +4 -5
  72. package/dist/lib/session/active.d.ts +16 -2
  73. package/dist/lib/session/active.js +8 -4
  74. package/dist/lib/session/bundle.js +5 -1
  75. package/dist/lib/session/remote-list.d.ts +1 -15
  76. package/dist/lib/session/remote-list.js +22 -111
  77. package/dist/lib/session/sync/config.d.ts +13 -5
  78. package/dist/lib/session/sync/config.js +21 -13
  79. package/dist/lib/startup/command-registry.d.ts +0 -2
  80. package/dist/lib/startup/command-registry.js +1 -5
  81. package/dist/lib/state.d.ts +7 -2
  82. package/dist/lib/state.js +85 -10
  83. package/dist/lib/sync-umbrella.d.ts +11 -23
  84. package/dist/lib/sync-umbrella.js +14 -45
  85. package/dist/lib/teams/agents.d.ts +35 -3
  86. package/dist/lib/teams/agents.js +52 -8
  87. package/dist/lib/teams/scheduler.d.ts +29 -2
  88. package/dist/lib/teams/scheduler.js +60 -16
  89. package/dist/lib/types.d.ts +17 -1
  90. package/package.json +1 -2
  91. package/dist/commands/drive.d.ts +0 -10
  92. package/dist/commands/drive.js +0 -183
  93. package/dist/commands/hq.d.ts +0 -2
  94. package/dist/commands/hq.js +0 -58
  95. package/dist/commands/sessions-sync.d.ts +0 -17
  96. package/dist/commands/sessions-sync.js +0 -135
  97. package/dist/commands/sync-provision.d.ts +0 -23
  98. package/dist/commands/sync-provision.js +0 -107
  99. package/dist/lib/drive-sync.d.ts +0 -45
  100. package/dist/lib/drive-sync.js +0 -238
  101. package/dist/lib/hq/floor.d.ts +0 -87
  102. package/dist/lib/hq/floor.js +0 -243
  103. package/dist/lib/session/sync/crdt.d.ts +0 -44
  104. package/dist/lib/session/sync/crdt.js +0 -119
  105. package/dist/lib/session/sync/manifest.d.ts +0 -62
  106. package/dist/lib/session/sync/manifest.js +0 -100
  107. package/dist/lib/session/sync/provision.d.ts +0 -49
  108. package/dist/lib/session/sync/provision.js +0 -91
  109. package/dist/lib/session/sync/r2.d.ts +0 -32
  110. package/dist/lib/session/sync/r2.js +0 -121
  111. package/dist/lib/session/sync/sync.d.ts +0 -106
  112. package/dist/lib/session/sync/sync.js +0 -374
package/dist/index.js CHANGED
@@ -94,11 +94,12 @@ if (IS_DEV_BUILD) {
94
94
  // module on each invocation (which loaded the whole ~50-module tree before the
95
95
  // first byte of output), the registry maps a command name to a thunk that
96
96
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
97
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadPerf, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadHq, loadFeed, loadActivity, loadMailboxes, } from './lib/startup/command-registry.js';
97
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadPerf, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadFeed, loadActivity, loadMailboxes, } from './lib/startup/command-registry.js';
98
98
  import { applyGlobalHelpConventions } from './lib/help.js';
99
99
  import { renderWhatsNew } from './lib/whats-new.js';
100
100
  import { getCliLaunch } from './lib/cli-entry.js';
101
101
  import { emit, emitFriction, redactArgs } from './lib/events.js';
102
+ import { die } from './lib/format.js';
102
103
  // Transparent shim delegate: the generated Windows `.cmd` shims invoke
103
104
  // `agents __shim <agent>[@version] <raw args>`. Intercept here, before commander
104
105
  // parses anything, so the agent's own flags (`--help`, `--version`, etc.) pass
@@ -281,7 +282,6 @@ Run and dispatch:
281
282
  run <agent|profile> [prompt] Run an agent. Omit prompt for interactive mode.
282
283
  defaults Configure run defaults by agent/version selector
283
284
  teams Coordinate multiple agents on shared work
284
- hq JSON bridge for the interactive Agents HQ floor
285
285
  routines Run agents on a cron schedule (scheduler auto-starts)
286
286
  webhook Receive signed GitHub/Linear webhooks for trigger routines
287
287
  funnel Expose a webhook receiver through Tailscale Funnel
@@ -301,14 +301,13 @@ Diagnostics:
301
301
  perf Latency rollups (hooks, commands, runs) from the disposable perf warehouse
302
302
 
303
303
  Config sync:
304
- drive Sync session history across machines via rsync
305
304
  pull Clone or pull the system repo at ~/.agents/.system/
306
305
  repo init --path <dir> Scaffold your own editable repo from a template
307
306
  repo add <path|gh:user/repo> Merge an extra repo after the system repo
308
307
  lock [--frozen] Write/verify agents.lock (SHA-256 of resolved resources); --frozen fails on drift
309
308
 
310
309
  Beta features:
311
- beta Enable preview features (factory, drive, and more)
310
+ beta Enable preview features (factory and more)
312
311
 
313
312
  Automation tips:
314
313
  Pass explicit names/IDs Avoid pickers: agents sessions <id> --markdown
@@ -721,6 +720,21 @@ function registerResourcesTombstoneCommand(p) {
721
720
  await program.parseAsync(['node', 'agents', ...args]);
722
721
  });
723
722
  }
723
+ /**
724
+ * Removed `hq` command — the JSON bridge for the interactive Agents HQ floor
725
+ * (`agents hq floor --json`). No UI ever consumed it (apps/factory has zero
726
+ * references) and it had no external users, so it is gone with no replacement.
727
+ * Kept as a hidden tombstone so a stale invocation gets a clear message and a
728
+ * non-zero exit instead of commander's raw "unknown command".
729
+ */
730
+ function registerHqTombstoneCommand(p) {
731
+ p.command('hq', { hidden: true })
732
+ .allowUnknownOption()
733
+ .allowExcessArguments()
734
+ .action(() => {
735
+ die('"agents hq" was removed (internal Agents HQ floor bridge, no longer used).');
736
+ });
737
+ }
724
738
  /**
725
739
  * Hidden `agents _internal <sub>` namespace for machine-to-machine calls that
726
740
  * are not user-facing. The first subcommand is `friction`, used by shell guard
@@ -840,6 +854,9 @@ async function registerEagerForRequest(name) {
840
854
  registerResourcesTombstoneCommand(program);
841
855
  await reg(loadView);
842
856
  return true;
857
+ case 'hq':
858
+ registerHqTombstoneCommand(program);
859
+ return true;
843
860
  case '_internal':
844
861
  registerInternalCommand(program);
845
862
  return true;
@@ -912,7 +929,6 @@ async function registerAllEagerCommands() {
912
929
  await reg(loadSync);
913
930
  await reg(loadLock);
914
931
  await reg(loadRefreshRules);
915
- await reg(loadDrive);
916
932
  await reg(loadFactory);
917
933
  await reg(loadUsage);
918
934
  await reg(loadCost);
@@ -932,7 +948,7 @@ async function registerAllEagerCommands() {
932
948
  await reg(loadAudit);
933
949
  await reg(loadWebhook);
934
950
  await reg(loadFunnel);
935
- await reg(loadHq);
951
+ registerHqTombstoneCommand(program);
936
952
  await reg(loadFeed);
937
953
  await reg(loadActivity);
938
954
  await reg(loadMailboxes);
@@ -1,3 +1,4 @@
1
+ import type { ActorKind } from './actor.js';
1
2
  import type { EventRecord } from './events.js';
2
3
  /** Recognizable milestone events, ordered first in any activity lane. */
3
4
  export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created'
@@ -80,6 +81,10 @@ export interface ActivityEvent {
80
81
  project?: string;
81
82
  /** Agent that produced the event (claude, codex, ...). */
82
83
  agent?: string;
84
+ /** Resolved actor id shared with the operational event stream. */
85
+ actor?: string;
86
+ /** Resolved actor kind shared with the operational event stream. */
87
+ kind?: ActorKind | 'unknown';
83
88
  /** Tool that triggered the event (Bash, Task, ExitPlanMode, feed.post, ...). */
84
89
  tool?: string;
85
90
  /** One-line human summary (plan title, PR command, sub-agent role, status text). */
@@ -90,6 +95,8 @@ export interface ActivityEvent {
90
95
  pid?: number;
91
96
  /** Spawn-time join key (`AGENT_LAUNCH_ID`) when known. */
92
97
  launchId?: string;
98
+ /** Session that spawned this session (`AGENTS_PARENT_SESSION_ID`) when known. */
99
+ parentSessionId?: string;
93
100
  /** Factory terminal id when the launch inherited one. */
94
101
  terminalId?: string;
95
102
  /** `$TMUX_PANE` at launch when recorded. */
@@ -25,6 +25,7 @@ import { relTime, truncate } from './format.js';
25
25
  import { getActivityDir, getUserAgentsDir } from './state.js';
26
26
  import { normalizeHost } from './machine-id.js';
27
27
  import { projectKeyFromCwd } from './project-key.js';
28
+ import { stampProvenance } from './event-provenance.js';
28
29
  /** The set of milestone events, for tier classification and ordering. */
29
30
  export const MILESTONE_EVENTS = [
30
31
  'plan.created',
@@ -109,6 +110,7 @@ export function appendActivityEvent(event, root) {
109
110
  const dir = root ?? getActivityDir();
110
111
  fs.mkdirSync(dir, { recursive: true });
111
112
  const record = {
113
+ ...stampProvenance(),
112
114
  v: event.v ?? 1,
113
115
  tier: event.tier ?? tierForEvent(event.event),
114
116
  ...event,
@@ -135,11 +137,14 @@ function parseLine(line) {
135
137
  cwd: parsed.cwd,
136
138
  project: parsed.project,
137
139
  agent: parsed.agent,
140
+ actor: parsed.actor,
141
+ kind: parsed.kind,
138
142
  tool: parsed.tool,
139
143
  detail: parsed.detail,
140
144
  url: parsed.url,
141
145
  pid: typeof parsed.pid === 'number' ? parsed.pid : undefined,
142
146
  launchId: parsed.launchId,
147
+ parentSessionId: parsed.parentSessionId,
143
148
  terminalId: parsed.terminalId,
144
149
  tmuxPane: parsed.tmuxPane,
145
150
  category: typeof parsed.category === 'string' ? parsed.category : undefined,
@@ -268,10 +273,12 @@ export function activityEventToRecord(ev) {
268
273
  level: 'info',
269
274
  caller: ev.tool === 'feed.post' ? 'agent' : 'hook',
270
275
  session: ev.sessionId,
271
- osUser: ev.agent ?? 'agent',
276
+ osUser: 'unknown',
272
277
  transport: 'local',
273
278
  // payload
274
279
  agent: ev.agent,
280
+ actor: ev.actor ?? 'unknown',
281
+ kind: ev.kind ?? 'unknown',
275
282
  sessionId: ev.sessionId,
276
283
  cwd: ev.cwd,
277
284
  module: 'activity',
@@ -281,6 +288,7 @@ export function activityEventToRecord(ev) {
281
288
  tier: ev.tier,
282
289
  ...(ev.project ? { project: ev.project } : {}),
283
290
  ...(ev.launchId ? { launchId: ev.launchId } : {}),
291
+ ...(ev.parentSessionId ? { parentSessionId: ev.parentSessionId } : {}),
284
292
  ...(ev.terminalId ? { terminalId: ev.terminalId } : {}),
285
293
  ...(ev.tmuxPane ? { tmuxPane: ev.tmuxPane } : {}),
286
294
  ...(ev.attachments?.length ? { attachments: ev.attachments } : {}),
@@ -1521,8 +1529,17 @@ def main():
1521
1529
  cwd = payload.get("cwd") or os.environ.get("AGENTS_CWD")
1522
1530
  if cwd:
1523
1531
  record["cwd"] = cwd
1524
- agent = os.environ.get("AGENTS_AGENT_NAME") or "claude"
1532
+ agent = os.environ.get("AGENTS_AGENT_NAME") or "unknown"
1525
1533
  record["agent"] = agent
1534
+ record["actor"] = os.environ.get("AGENTS_ACTOR") or "unknown"
1535
+ actor_kind = os.environ.get("AGENTS_ACTOR_KIND")
1536
+ record["kind"] = actor_kind if actor_kind in ("human", "agent") else "unknown"
1537
+ launch_id = os.environ.get("AGENT_LAUNCH_ID")
1538
+ if launch_id:
1539
+ record["launchId"] = launch_id
1540
+ parent_session_id = os.environ.get("AGENTS_PARENT_SESSION_ID")
1541
+ if parent_session_id:
1542
+ record["parentSessionId"] = parent_session_id
1526
1543
 
1527
1544
  try:
1528
1545
  os.makedirs(activity_dir, exist_ok=True)
@@ -43,7 +43,12 @@ export interface PlannedDispatch {
43
43
  title: string;
44
44
  delegateName: string;
45
45
  }
46
- /** Path to the shared factory project registry (written by the factory UI). */
46
+ /**
47
+ * Path to the shared factory project registry (written by the factory UI).
48
+ * `AGENTS_FACTORY_PROJECTS_PATH` overrides it, mirroring `AGENTS_PROJECTS_DIR`
49
+ * (`state.ts`) — that seam is what lets `projects import --from-factory` be
50
+ * tested end-to-end against a real fixture file instead of a mock.
51
+ */
47
52
  export declare function factoryProjectsPath(): string;
48
53
  /** Read the project registry rows this module cares about. */
49
54
  export declare function readAutoDispatchProjects(): AutoDispatchProject[];
@@ -16,9 +16,14 @@
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { homedir } from 'os';
19
- /** Path to the shared factory project registry (written by the factory UI). */
19
+ /**
20
+ * Path to the shared factory project registry (written by the factory UI).
21
+ * `AGENTS_FACTORY_PROJECTS_PATH` overrides it, mirroring `AGENTS_PROJECTS_DIR`
22
+ * (`state.ts`) — that seam is what lets `projects import --from-factory` be
23
+ * tested end-to-end against a real fixture file instead of a mock.
24
+ */
20
25
  export function factoryProjectsPath() {
21
- return path.join(homedir(), '.agents', 'factory', 'projects.json');
26
+ return process.env.AGENTS_FACTORY_PROJECTS_PATH ?? path.join(homedir(), '.agents', 'factory', 'projects.json');
22
27
  }
23
28
  /** Read the project registry rows this module cares about. */
24
29
  export function readAutoDispatchProjects() {
@@ -8,7 +8,7 @@
8
8
  * checks.
9
9
  */
10
10
  import type { BetaFeatureName } from './types.js';
11
- export declare const ALL_BETA_FEATURES: readonly ["drive", "factory", "session-sync", "projects"];
11
+ export declare const ALL_BETA_FEATURES: readonly ["factory", "projects"];
12
12
  export declare function getEnabledBetaFeatures(): BetaFeatureName[];
13
13
  export declare function isBetaEnabled(feature: BetaFeatureName): boolean;
14
14
  export declare function getBetaConfigLocation(): {
package/dist/lib/beta.js CHANGED
@@ -10,7 +10,7 @@
10
10
  import * as path from 'path';
11
11
  import { getAgentsDir, getOptionalUserAgentsDir, readMeta, writeMeta } from './state.js';
12
12
  import { readManifest, writeManifest } from './manifest.js';
13
- export const ALL_BETA_FEATURES = ['drive', 'factory', 'session-sync', 'projects'];
13
+ export const ALL_BETA_FEATURES = ['factory', 'projects'];
14
14
  function isBetaFeatureName(value) {
15
15
  return typeof value === 'string' && ALL_BETA_FEATURES.includes(value);
16
16
  }
@@ -16,6 +16,19 @@ import { getAgentsBinPath } from './cli-entry.js';
16
16
  * broker is hosting, and never clobber a reachable (healthy) broker.
17
17
  */
18
18
  export declare function shouldTakeOverBroker(isHosting: boolean, brokerReachable: boolean): boolean;
19
+ /**
20
+ * What a gate re-evaluation must do with the routines scheduler. The daemon
21
+ * re-evaluates `scheduler.enabled` on every SIGHUP reload so flipping the key
22
+ * takes effect without a daemon restart (and `routines add`'s reload signal on
23
+ * a re-enabled box boots the scheduler — the reload is truthful, not a no-op).
24
+ *
25
+ * running + enabled → reload (the normal SIGHUP path)
26
+ * running + !enabled → stop (gate flipped off since boot)
27
+ * !running + enabled → boot (gate flipped on since boot)
28
+ * !running + !enabled → none (stay dark)
29
+ */
30
+ export type SchedulerGateTransition = 'reload' | 'stop' | 'boot' | 'none';
31
+ export declare function schedulerGateTransition(running: boolean, enabled: boolean): SchedulerGateTransition;
19
32
  /** Read the stored daemon PID from disk. Returns null if not present or invalid. */
20
33
  export declare function readDaemonPid(): number | null;
21
34
  /** Write the daemon PID to the pid file. */
@@ -24,6 +24,7 @@ import { BrowserService } from './browser/service.js';
24
24
  import { BrowserIPCServer } from './browser/ipc.js';
25
25
  import { redactSecrets } from './redact.js';
26
26
  import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from './cli-entry.js';
27
+ import { isSchedulerEnabled, assertSchedulerEnabled } from './device-config.js';
27
28
  const PID_FILE = 'daemon.pid';
28
29
  const LOCK_FILE = 'daemon.lock';
29
30
  const LOG_FILE = 'logs.jsonl';
@@ -53,6 +54,11 @@ const WEDGE_THRESHOLD_TICKS = 3;
53
54
  export function shouldTakeOverBroker(isHosting, brokerReachable) {
54
55
  return !isHosting && !brokerReachable;
55
56
  }
57
+ export function schedulerGateTransition(running, enabled) {
58
+ if (running)
59
+ return enabled ? 'reload' : 'stop';
60
+ return enabled ? 'boot' : 'none';
61
+ }
56
62
  function getDaemonDir() {
57
63
  const dir = getDaemonDirRoot();
58
64
  fs.mkdirSync(dir, { recursive: true });
@@ -398,7 +404,7 @@ export async function runDaemon() {
398
404
  log('ERROR', `Stray daemon reaper failed: ${err.message}`);
399
405
  }
400
406
  // #416: host the secrets broker socket-first — before the scheduler and the
401
- // heavy browser/session-sync services — so `agents secrets` resolves within
407
+ // heavy browser services — so `agents secrets` resolves within
402
408
  // ms of daemon start. Only host when no broker is already reachable, so we
403
409
  // never orphan a live standalone broker's clients (that broker stays the
404
410
  // server until it idle-exits or the daemon restarts). Best-effort: a failure
@@ -419,7 +425,23 @@ export async function runDaemon() {
419
425
  catch (err) {
420
426
  log('WARN', `Secrets broker host skipped: ${err.message}`);
421
427
  }
422
- const scheduler = new JobScheduler(async (config) => {
428
+ // scheduler.enabled=false in this machine's device doc means NO routines fire
429
+ // here — the scheduler and its catchup recovery simply never start, while the
430
+ // daemon keeps its other duties (secrets broker, browser IPC, session sync).
431
+ // The refusal message is the same one the start surfaces
432
+ // (`routines add` auto-start, manual `routines start`) raise. The gate is
433
+ // re-evaluated on every SIGHUP reload (handleReload below) via
434
+ // schedulerGateTransition, so flipping the key never needs a daemon restart.
435
+ const schedulerEnabledAtBoot = isSchedulerEnabled();
436
+ if (!schedulerEnabledAtBoot) {
437
+ try {
438
+ assertSchedulerEnabled();
439
+ }
440
+ catch (err) {
441
+ log('WARN', err.message);
442
+ }
443
+ }
444
+ const triggerJob = async (config) => {
423
445
  const jobLabel = config.command
424
446
  ? 'command'
425
447
  : config.workflow
@@ -458,7 +480,36 @@ export async function runDaemon() {
458
480
  }
459
481
  catch { /* best-effort */ }
460
482
  }
461
- });
483
+ };
484
+ let scheduler = null;
485
+ let catchupInterval;
486
+ // Catchup overlap guard. Declared up here (not beside catchupPass) because
487
+ // bootScheduler() runs before catchupPass's textual position — a `let` down
488
+ // there would still be in its TDZ at the first call and crash the daemon.
489
+ let catchingUp = false;
490
+ // Boot the scheduler + catchup recovery. Called at daemon start when the gate
491
+ // allows, and again from handleReload when the gate flips on (function
492
+ // declarations hoist — catchupPass below is in scope).
493
+ function bootScheduler() {
494
+ scheduler = new JobScheduler(triggerJob);
495
+ scheduler.loadAll();
496
+ const scheduled = scheduler.listScheduled();
497
+ log('INFO', `Loaded ${scheduled.length} jobs`);
498
+ for (const job of scheduled) {
499
+ log('INFO', ` ${job.name} -> next: ${job.nextRun?.toISOString() || 'unknown'}`);
500
+ }
501
+ void catchupPass();
502
+ catchupInterval = setInterval(() => { void catchupPass(); }, CATCHUP_TICK_MS);
503
+ }
504
+ // Stop the scheduler + catchup recovery (gate flipped off on reload).
505
+ function stopScheduler() {
506
+ scheduler?.stopAll();
507
+ scheduler = null;
508
+ if (catchupInterval !== undefined) {
509
+ clearInterval(catchupInterval);
510
+ catchupInterval = undefined;
511
+ }
512
+ }
462
513
  // Materialise opted-in project routines into the user layer on every start
463
514
  // so a fresh daemon picks up project YAML without a separate sync step.
464
515
  try {
@@ -470,12 +521,8 @@ export async function runDaemon() {
470
521
  catch (err) {
471
522
  log('WARN', `Project routines sync failed: ${err.message}`);
472
523
  }
473
- scheduler.loadAll();
474
- const scheduled = scheduler.listScheduled();
475
- log('INFO', `Loaded ${scheduled.length} jobs`);
476
- for (const job of scheduled) {
477
- log('INFO', ` ${job.name} -> next: ${job.nextRun?.toISOString() || 'unknown'}`);
478
- }
524
+ if (schedulerEnabledAtBoot)
525
+ bootScheduler();
479
526
  // Monitor engine: event-triggered watchers, beside the cron scheduler. Same
480
527
  // daemon, same dispatch seam — a monitor is a routine whose trigger is a
481
528
  // watched source instead of a clock. Reloads on SIGHUP alongside the scheduler.
@@ -495,15 +542,16 @@ export async function runDaemon() {
495
542
  // `catchup: false`, RUN late. Runs on a timer as well as at startup: a startup
496
543
  // pass alone misses a fire lost while the daemon stayed up but its event loop
497
544
  // was wedged, or one lost across an OS suspend that the process survived.
498
- // Overlap guard, same shape as runSessionSync/runHealCheck below. A pass
545
+ // Overlap guard, same shape as runHealCheck below. A pass
499
546
  // awaits executeJobDetached per job and an off-box (host/cloud) dispatch can
500
547
  // block for a while, so a slow pass could still be working when the next tick
501
548
  // fires. Both passes would then see a job the first has not yet reached as
502
549
  // overdue — the miss is recorded before the await, but only for jobs already
503
550
  // processed — and spawn it twice. The idempotency of the `missed` record
504
551
  // guards across passes, not within one that is mid-flight.
505
- let catchingUp = false;
506
- const catchupPass = async () => {
552
+ // Function declaration (hoisted) so bootScheduler() can schedule it. Its
553
+ // guard (`catchingUp`) is declared beside `scheduler` above for TDZ safety.
554
+ async function catchupPass() {
507
555
  if (catchingUp)
508
556
  return;
509
557
  catchingUp = true;
@@ -553,9 +601,7 @@ export async function runDaemon() {
553
601
  // throw must not leave the guard latched shut for the daemon's lifetime.
554
602
  catchingUp = false;
555
603
  }
556
- };
557
- await catchupPass();
558
- const catchupInterval = setInterval(() => { void catchupPass(); }, CATCHUP_TICK_MS);
604
+ }
559
605
  // Before the BrowserService comes up, reap browser + tunnel processes
560
606
  // spawned by previous daemons that are no longer alive. Without this,
561
607
  // a daemon hard-crash (SIGKILL, OOM) would leak every browser and SSH
@@ -588,42 +634,6 @@ export async function runDaemon() {
588
634
  writeHeartbeat();
589
635
  monitorRunningJobs();
590
636
  }, MONITOR_TICK_MS);
591
- // Cross-machine session sync: push this machine's transcripts to R2 and pull
592
- // every other machine's, ~every 90s. Skipped silently when the r2.backups
593
- // bundle is absent. An overlap guard prevents a slow cycle from stacking.
594
- let syncing = false;
595
- const runSessionSync = async () => {
596
- if (syncing)
597
- return;
598
- syncing = true;
599
- try {
600
- const { isBetaEnabled } = await import('./beta.js');
601
- // Off by default: session sync is an opt-in beta feature. Check the beta
602
- // flag FIRST so a machine that hasn't opted in skips the keychain read
603
- // (isSyncConfigured) entirely, not just the network cycle.
604
- if (!isBetaEnabled('session-sync'))
605
- return;
606
- const { isSyncConfigured } = await import('./session/sync/config.js');
607
- if (!isSyncConfigured())
608
- return;
609
- const { syncSessions } = await import('./session/sync/sync.js');
610
- const r = await syncSessions();
611
- if (r.pushed || r.pulled || r.errors.length) {
612
- log('INFO', `sessions sync: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}` +
613
- (r.errors.length ? `, ${r.errors.length} error(s): ${r.errors[0]}` : ''));
614
- }
615
- if (r.warnings.length)
616
- log('WARN', `sessions sync: ${r.warnings[0]}`);
617
- }
618
- catch (err) {
619
- log('ERROR', `sessions sync failed: ${err.message}`);
620
- }
621
- finally {
622
- syncing = false;
623
- }
624
- };
625
- const syncInterval = setInterval(() => { void runSessionSync(); }, 90_000);
626
- void runSessionSync(); // kick once at startup
627
637
  // Resource safety check: heal gaps between what DotAgents repos define and
628
638
  // what's actually installed in each agent home — the slow rot that nothing
629
639
  // else catches (a non-default version left stale, a Claude-invalid plugin
@@ -875,27 +885,37 @@ export async function runDaemon() {
875
885
  catch (err) {
876
886
  log('WARN', `Project routines sync failed: ${err.message}`);
877
887
  }
878
- scheduler.reloadAll();
879
- const reloaded = scheduler.listScheduled();
880
- log('INFO', `Reloaded ${reloaded.length} jobs`);
888
+ // Re-evaluate the scheduler.enabled gate: flipping the key takes effect on
889
+ // this reload, no daemon restart needed. A `routines add` on a re-enabled
890
+ // box signals exactly this reload, which boots the scheduler — the
891
+ // "Scheduler reloaded" it prints is then truthful, not a dead-end.
892
+ const transition = schedulerGateTransition(scheduler !== null, isSchedulerEnabled());
893
+ if (transition === 'boot') {
894
+ log('INFO', 'scheduler.enabled is now on — booting the scheduler');
895
+ bootScheduler();
896
+ }
897
+ else if (transition === 'stop') {
898
+ log('WARN', 'scheduler.enabled is now off — stopping the scheduler; no routines will fire on this device');
899
+ stopScheduler();
900
+ }
901
+ else if (transition === 'reload') {
902
+ scheduler.reloadAll();
903
+ const reloaded = scheduler.listScheduled();
904
+ log('INFO', `Reloaded ${reloaded.length} jobs`);
905
+ }
881
906
  try {
882
907
  monitorEngine.reload();
883
908
  }
884
909
  catch (err) {
885
910
  log('ERROR', `Monitor engine reload failed: ${err.message}`);
886
911
  }
887
- // Drop the memoized R2 config so rotated/added sync credentials are re-read
888
- // on the next cycle instead of waiting for a restart.
889
- void import('./session/sync/config.js').then(m => m.clearR2ConfigCache());
890
912
  };
891
913
  const handleShutdown = async () => {
892
914
  log('INFO', 'Daemon shutting down');
893
- scheduler.stopAll();
915
+ stopScheduler();
894
916
  monitorEngine.stop();
895
917
  await browserIPC.stop();
896
918
  clearInterval(monitorInterval);
897
- clearInterval(catchupInterval);
898
- clearInterval(syncInterval);
899
919
  clearInterval(healInterval);
900
920
  clearTimeout(healKickoff);
901
921
  clearInterval(autoDispatchInterval);
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Device/user config keys — typed read/write over the two-tier agents.yaml store.
3
+ *
4
+ * One registry (`CONFIG_KEYS`) maps each CLI dotted name to where it lives:
5
+ * - user scope → central `~/.agents/agents.yaml` under `config:` (syncs
6
+ * fleet-wide via `agents repo push/pull`)
7
+ * - device scope → `~/.agents/devices/<host>/agents.yaml` under `config:`
8
+ * (per-machine; mirrors how `defaultBrowserProfile` is routed)
9
+ *
10
+ * This machine's keys go through the readMeta/updateMeta funnel (state.ts) so the
11
+ * partition/overlay logic stays the single writer. Another device's doc is
12
+ * read/written in place — the devices/ tree syncs via the DotAgents repo, so
13
+ * editing `devices/mac-mini/agents.yaml` locally is how `configure`/`note`
14
+ * target a peer (`--device`-style).
15
+ *
16
+ * `browser.profile` is NOT a `config:` key — it is the existing
17
+ * `Meta.defaultBrowserProfile` field; the registry entry documents the mapping
18
+ * and set/get route to it so there is one source of truth (no duplicate key).
19
+ *
20
+ * Unset always means today's behavior.
21
+ */
22
+ /** Which tier of the agents.yaml store a key lives in. */
23
+ export type ConfigScope = 'user' | 'device';
24
+ /** Value type of a config key — drives validation and `--json` rendering. */
25
+ export type ConfigType = 'string' | 'int' | 'bool' | 'string-list';
26
+ /** One known config key. */
27
+ export interface ConfigKeySpec {
28
+ /** CLI dotted name, e.g. `interactive.host`. */
29
+ name: string;
30
+ /** camelCase key under the YAML `config:` block. */
31
+ yamlKey: string;
32
+ scope: ConfigScope;
33
+ type: ConfigType;
34
+ /** One-line description for help/list output. */
35
+ description: string;
36
+ /**
37
+ * When set, the value lives in this top-level Meta field instead of the
38
+ * `config:` block (only `browser.profile` → `defaultBrowserProfile` today).
39
+ */
40
+ field?: 'defaultBrowserProfile';
41
+ /** Extra validation beyond the type check; return an error string or null. */
42
+ validate?: (value: unknown) => string | null;
43
+ }
44
+ /** A key with its resolved value and the layer that set it. */
45
+ export interface ConfigEntry {
46
+ spec: ConfigKeySpec;
47
+ /** The stored value, or undefined when unset (unset = default behavior). */
48
+ value: unknown;
49
+ /** Which layer set it; undefined when unset. */
50
+ layer?: ConfigScope;
51
+ }
52
+ /** Options scoping a read/write to a specific device's doc (default: this machine). */
53
+ export interface ConfigTarget {
54
+ device?: string;
55
+ }
56
+ export declare const CONFIG_KEYS: readonly ConfigKeySpec[];
57
+ /** Look up a key spec by CLI dotted name, or throw listing the known keys. */
58
+ export declare function configKeySpec(name: string): ConfigKeySpec;
59
+ /** Get one config key's value and the layer that set it. */
60
+ export declare function getConfigValue(name: string, opts?: ConfigTarget): ConfigEntry;
61
+ /** List every known key with its value and the layer that set it. */
62
+ export declare function listConfig(opts?: ConfigTarget): ConfigEntry[];
63
+ /** Set a config key (validated). Device-scope keys target this machine unless `opts.device` names a peer. */
64
+ export declare function setConfigValue(name: string, value: unknown, opts?: ConfigTarget): void;
65
+ /** Unset a config key — restores default behavior. No-op when already unset. */
66
+ export declare function unsetConfigValue(name: string, opts?: ConfigTarget): void;
67
+ /** True unless this machine's device doc disables the routines scheduler. */
68
+ export declare function isSchedulerEnabled(): boolean;
69
+ /**
70
+ * Throw when the routines scheduler is disabled on this machine, naming the
71
+ * setting and the fix. The single message every scheduler-start surface
72
+ * (auto-start on `routines add`, manual `routines start`, the daemon's own
73
+ * scheduler init) refuses with.
74
+ */
75
+ export declare function assertSchedulerEnabled(): void;
76
+ /**
77
+ * Read the `agents.max-concurrent` cap for each named device from its synced
78
+ * device doc (no SSH). Devices without a cap are omitted — uncapped is the
79
+ * default. Used as an input to host ranking (teams placement, Factory
80
+ * auto-launch), never as a remote probe.
81
+ */
82
+ export declare function readMaxConcurrentCaps(devices: string[]): Record<string, number>;