@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
@@ -25,6 +25,21 @@ import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../vers
25
25
  const APP_BUNDLE_NAME = 'MenubarHelper.app';
26
26
  const INSTALL_DIR_NAME = 'agents-cli';
27
27
  const SERVICE_LABEL = 'com.phnx-labs.agents-menubar';
28
+ /**
29
+ * Minimum seconds between launchd restarts of the helper (`ThrottleInterval`).
30
+ *
31
+ * The helper can crash at startup on a loaded machine: `NSApplication.shared`
32
+ * segfaults inside `SLSNewConnection` when WindowServer is too starved to hand
33
+ * out a connection. With `KeepAlive` and no throttle, launchd relaunches on its
34
+ * 10s default, and each attempt spawns a fresh `agents doctor --json` before
35
+ * dying — so a starved box gets hit harder the worse it gets. 30s bounds that
36
+ * respawn rate while staying well inside "the menu bar came back on its own".
37
+ *
38
+ * This only paces the restarts. What actually stops the pile-up is the helper
39
+ * bounding and group-killing its own children (menubar/Sources/MenubarHelper/
40
+ * ChildProcess.swift); the two are complementary, not alternatives.
41
+ */
42
+ const MENUBAR_THROTTLE_SECONDS = 30;
28
43
  function onDarwin() {
29
44
  return process.platform === 'darwin';
30
45
  }
@@ -181,41 +196,15 @@ export function codesignVerifies(appPath) {
181
196
  * A Developer-ID-signed but un-notarized app is rejected by `spctl --assess`,
182
197
  * which macOS surfaces as "the app is damaged" and can crash AppKit during
183
198
  * launch. This is separate from `codesign --verify`: a signature can be valid
184
- * while Gatekeeper still refuses to run it.
199
+ * while Gatekeeper still refuses to run it. The release notarizes + staples the
200
+ * helper (menubar/scripts/build.sh, gated by verify-menubar-helper.sh), so a
201
+ * shipped bundle passes this; the launch guards use it to fail loud rather than
202
+ * bootstrap a helper macOS would reject.
185
203
  */
186
204
  export function gatekeeperAssesses(appPath) {
187
205
  const r = spawnSync('spctl', ['--assess', '--type', 'exec', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
188
206
  return r.status === 0;
189
207
  }
190
- /**
191
- * Guarantee the installed bundle has a signature Gatekeeper will accept on THIS
192
- * machine.
193
- *
194
- * npm's pack/extract strips the ad-hoc/linker signature the release baked into
195
- * the helper, leaving `code object is not signed at all`. On macOS 26+ the
196
- * kernel's code-signing monitor SIGKILLs an unsigned/invalid binary at launch
197
- * (`SIGKILL (Code Signature Invalid)`), so under the launchd `KeepAlive` service
198
- * an ad-hoc release helper crash-loops forever and its unstable identity makes
199
- * the Accessibility grant (needed for the clip→paste keystroke in Clip.swift)
200
- * re-prompt every time. A fresh ad-hoc re-sign gives the on-disk bytes a
201
- * matching cdhash, which the kernel accepts.
202
- *
203
- * A Developer-ID-signed helper survives npm untouched — its embedded signature
204
- * still verifies — but if the release was not notarized, Gatekeeper rejects it.
205
- * In that case we strip the quarantine xattr and re-sign ad-hoc so the helper
206
- * can launch locally. The stable fix is to notarize the release build; this
207
- * fallback just prevents a crash-loop while the user is on an un-notarized cut.
208
- */
209
- export function ensureValidSignature(appPath) {
210
- if (codesignVerifies(appPath) && gatekeeperAssesses(appPath))
211
- return true;
212
- // Drop any quarantine/xattrs the tarball round-trip added (they can break
213
- // both codesign and Gatekeeper), then re-sign ad-hoc under the helper's
214
- // stable bundle identifier.
215
- spawnSync('xattr', ['-cr', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
216
- spawnSync('codesign', ['--force', '--sign', '-', '--identifier', SERVICE_LABEL, appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
217
- return codesignVerifies(appPath);
218
- }
219
208
  /**
220
209
  * Copy the bundled `.app` to the stable user path (idempotent unless forced).
221
210
  * Returns the installed executable path, or null if no source bundle ships
@@ -229,13 +218,9 @@ export function ensureMenubarAppInstalled(opts = {}) {
229
218
  return null;
230
219
  const dest = installedAppPath();
231
220
  if (!opts.forceReinstall && fs.existsSync(dest)) {
232
- // Self-heal an already-installed bundle whose signature npm stripped on a
233
- // prior upgrade (macOS 26+ SIGKILLs it otherwise) without a forced recopy.
234
- ensureValidSignature(dest);
235
221
  return installedExecutablePath();
236
222
  }
237
223
  copyAppBundle(src, dest);
238
- ensureValidSignature(dest);
239
224
  // A fresh copy is exactly when the bundle's icon can be new (first install) or
240
225
  // superseded (upgrade) — register it so LaunchServices knows the bundle and can
241
226
  // resolve its AppIcon for the left-hand slot of daemon notifications.
@@ -245,7 +230,7 @@ export function ensureMenubarAppInstalled(opts = {}) {
245
230
  function xmlEscape(s) {
246
231
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
247
232
  }
248
- function generateServicePlist(execPath) {
233
+ export function generateServicePlist(execPath) {
249
234
  const home = os.homedir();
250
235
  const logPath = path.join(getHelpersDir(), 'menubar', 'menubar.log');
251
236
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
@@ -281,6 +266,8 @@ function generateServicePlist(execPath) {
281
266
  <true/>
282
267
  <key>KeepAlive</key>
283
268
  <true/>
269
+ <key>ThrottleInterval</key>
270
+ <integer>${MENUBAR_THROTTLE_SECONDS}</integer>
284
271
  <key>ProcessType</key>
285
272
  <string>Interactive</string>
286
273
  <key>StandardOutPath</key>
@@ -331,12 +318,15 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
331
318
  const exec = ensureMenubarAppInstalled({ forceReinstall: true });
332
319
  if (!exec)
333
320
  return false;
334
- // Never bootstrap a helper the kernel will kill on launch: an invalid
335
- // signature under launchd KeepAlive is an infinite crash loop. If the bundle
336
- // can't be made valid (re-sign already attempted in ensureMenubarAppInstalled),
337
- // skip the service rather than spin the loop.
338
- if (!codesignVerifies(installedAppPath())) {
339
- process.stderr.write('agents: menu-bar helper has no valid code signature; skipping launch to avoid a crash loop.\n');
321
+ // Never bootstrap a helper macOS will reject at launch: an invalid signature
322
+ // under launchd KeepAlive crash-loops forever, and an un-notarized bundle is
323
+ // rejected by Gatekeeper as "damaged". A shipped helper is Developer-ID signed
324
+ // AND notarized (release build + the verify-menubar-helper.sh prepack gate), so
325
+ // this passes; if it ever doesn't, skip the service and point at the upgrade
326
+ // rather than re-signing over it (an ad-hoc re-sign never satisfies Gatekeeper).
327
+ if (!(codesignVerifies(installedAppPath()) && gatekeeperAssesses(installedAppPath()))) {
328
+ process.stderr.write('agents: menu-bar helper is not notarized/valid on this machine; skipping launch. ' +
329
+ 'Upgrade to a notarized build (npm i -g @phnx-labs/agents-cli), then `agents menubar setup`.\n');
340
330
  return false;
341
331
  }
342
332
  if (opts.clearOptOut)
@@ -560,11 +550,12 @@ export function runMenubarSetup() {
560
550
  return { steps, configured: false, status: getMenubarStatus() };
561
551
  }
562
552
  step('bundle', before.installedVersion === getCliVersion() ? 'ok' : 'changed', `${installedAppPath()} (${getCliVersion()})`);
563
- if (!codesignVerifies(installedAppPath())) {
564
- step('signature', 'failed', 'no valid code signature — refusing to start it (launchd KeepAlive would crash-loop)');
553
+ if (!(codesignVerifies(installedAppPath()) && gatekeeperAssesses(installedAppPath()))) {
554
+ step('signature', 'failed', 'not notarized/valid on this machine — refusing to start it (Gatekeeper rejects an ' +
555
+ 'un-notarized helper as "damaged"). Upgrade to a notarized build of agents-cli.');
565
556
  return { steps, configured: false, status: getMenubarStatus() };
566
557
  }
567
- step('signature', 'ok', 'valid');
558
+ step('signature', 'ok', 'valid + notarized');
568
559
  // Clear the sticky opt-out: running `setup` is an explicit request for the
569
560
  // menu bar, so a stale `menubar disable` must not silently win.
570
561
  clearMenubarOptOut();
@@ -1708,7 +1708,7 @@ function migrateVersionResourcesToPatterns() {
1708
1708
  }
1709
1709
  }
1710
1710
  if (changed) {
1711
- const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n\n';
1711
+ const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n# yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agents-cli/main/apps/cli/schema/agents-yaml.schema.json\n\n';
1712
1712
  fs.writeFileSync(metaFile, META_HEADER + yaml.stringify(meta), 'utf-8');
1713
1713
  console.error('Migrated agents.yaml versions: entries to pattern format');
1714
1714
  }
@@ -20,7 +20,14 @@ const CACHE_PATH = getModelsCachePath();
20
20
  * Bump when the extractor logic changes shape in an incompatible way so cached
21
21
  * catalogs from older agents-cli builds are re-extracted.
22
22
  */
23
- const CACHE_SCHEMA_VERSION = 2;
23
+ const CACHE_SCHEMA_VERSION = 3;
24
+ /**
25
+ * How long a cached 0-model extraction is trusted before we retry it. Bounds
26
+ * the self-healing window for a transient failure (mid-install, a broken
27
+ * extractor regex fixed in a later agents-cli release) without falling back
28
+ * to re-extracting -- and re-scanning the whole binary -- on every call.
29
+ */
30
+ const EMPTY_CATALOG_RETRY_MS = 24 * 60 * 60 * 1000;
24
31
  let memoryCache = null;
25
32
  function cacheKey(agent, version) {
26
33
  return `${agent}@${version}`;
@@ -884,7 +891,10 @@ export function getModelCatalog(agent, version) {
884
891
  const key = cacheKey(agent, version);
885
892
  const cached = cache.entries[key];
886
893
  if (cached && cached.sourcePath === src.path && cached.mtime === mtime) {
887
- return cached.catalog;
894
+ const isFresh = cached.catalog.models.length > 0 ||
895
+ Date.now() - (cached.attemptedAt ?? 0) < EMPTY_CATALOG_RETRY_MS;
896
+ if (isFresh)
897
+ return cached.catalog;
888
898
  }
889
899
  let models = [];
890
900
  let aliases = {};
@@ -927,15 +937,15 @@ export function getModelCatalog(agent, version) {
927
937
  models,
928
938
  aliases,
929
939
  };
930
- // Never cache an empty extraction, regardless of source kind. A 0-model
931
- // result is always suspect: the CLI may have been mid-install, network-
932
- // dependent, or transiently failing, and a js/bundle/binary extractor that
933
- // regex-misses would otherwise pin an empty catalog forever (mtime won't
934
- // change until the source file does). Only persist a non-empty catalog.
935
- if (models.length > 0) {
936
- cache.entries[key] = { sourcePath: src.path, mtime, catalog };
937
- saveCache();
938
- }
940
+ // Cache a 0-model extraction too, stamped with when it was attempted, so a
941
+ // broken/mid-install extractor doesn't force a full re-scan of the source
942
+ // binary (up to ~1.85s each for a 230-270MB Claude binary) on every call --
943
+ // `getModelCatalog` runs once per installed version per invocation of
944
+ // commands like `agents view`. It self-heals: the read site above re-tries
945
+ // extraction once EMPTY_CATALOG_RETRY_MS has elapsed, or immediately once
946
+ // the source file's mtime changes (an upgrade/reinstall).
947
+ cache.entries[key] = { sourcePath: src.path, mtime, catalog, attemptedAt: Date.now() };
948
+ saveCache();
939
949
  return catalog;
940
950
  }
941
951
  /**
@@ -31,7 +31,7 @@ export interface OverdueJob {
31
31
  * Returns null when neither is available, which leaves the routine unfloored
32
32
  * (previous behaviour) rather than silently skipping it.
33
33
  */
34
- export declare function routineEffectiveStart(job: JobConfig): Date | null;
34
+ export declare function routineEffectiveStart(job: JobConfig, now?: Date): Date | null;
35
35
  /** Return every enabled, recurring job whose most recent expected fire was
36
36
  * missed. One-shot jobs are excluded — they fire at most once. */
37
37
  export declare function detectOverdueJobs(now?: Date): OverdueJob[];
@@ -13,29 +13,48 @@
13
13
  */
14
14
  import * as fs from 'fs';
15
15
  import { Cron } from 'croner';
16
- import { listJobs, getLatestRun, getJobPath, jobRunsOnThisDevice } from './routines.js';
16
+ import { listJobs, getLatestRun, resolveJobFilePath, isPastEndAt, isOneShotRoutine, jobRunsOnThisDevice } from './routines.js';
17
17
  import { notifyDesktop } from './menubar/notify-desktop.js';
18
18
  // Tolerance between "expected fire" and "recorded run start" — accounts for
19
19
  // the small gap between the cron tick and when the runner writes meta.json.
20
20
  const GRACE_MS = 60_000;
21
- const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
21
+ const DAY_MS = 24 * 60 * 60 * 1000;
22
+ /**
23
+ * Lookback windows, narrowest first. A fixed one-week window silently blinded
24
+ * detection to any cron whose gap exceeds it: `0 9 1,13,25 * *` has 12-day gaps,
25
+ * so `nextRun(now - 7d)` jumped past `now`, the walk returned null, and the
26
+ * routine was never flagged overdue on any device — no missed record, no
27
+ * catch-up, permanently. Monthly, quarterly and annual routines were all in that
28
+ * class.
29
+ *
30
+ * A wider window is only tried when the narrower one found nothing, so a dense
31
+ * schedule (every minute, hourly, daily) never walks more than a week of
32
+ * occurrences. A sparse schedule has few occurrences to walk by definition.
33
+ */
34
+ const LOOKBACK_WINDOWS_MS = [7 * DAY_MS, 32 * DAY_MS, 93 * DAY_MS, 400 * DAY_MS];
22
35
  /** Compute the most recent fire of `pattern` at or before `now`. Croner's
23
36
  * `previousRun()` returns the cron instance's own last fire, which is null
24
37
  * on a freshly-constructed instance — so we walk `nextRun(cursor)` forward
25
38
  * from a week ago and keep the last fire still ≤ now. */
26
39
  function previousExpectedFire(cron, now) {
27
- let cursor = new Date(now.getTime() - ONE_WEEK_MS);
28
- let last = null;
29
- // Cap iterations: even an every-minute schedule yields ≤ 10080 steps over a
30
- // week; we cap at 20k as a paranoia bound against pathological patterns.
31
- for (let i = 0; i < 20000; i++) {
32
- const next = cron.nextRun(cursor);
33
- if (!next || next.getTime() > now.getTime())
34
- break;
35
- last = next;
36
- cursor = next;
40
+ for (const window of LOOKBACK_WINDOWS_MS) {
41
+ let cursor = new Date(now.getTime() - window);
42
+ let last = null;
43
+ // Cap iterations: an every-minute schedule yields 10080 steps over a week;
44
+ // 20k is a paranoia bound against pathological patterns. Only a schedule
45
+ // that found nothing in the narrower window reaches a wider one, and such a
46
+ // schedule is sparse, so the cap is never the binding constraint.
47
+ for (let i = 0; i < 20000; i++) {
48
+ const next = cron.nextRun(cursor);
49
+ if (!next || next.getTime() > now.getTime())
50
+ break;
51
+ last = next;
52
+ cursor = next;
53
+ }
54
+ if (last)
55
+ return last;
37
56
  }
38
- return last;
57
+ return null;
39
58
  }
40
59
  /**
41
60
  * When a routine started existing, and therefore the earliest fire it can
@@ -49,13 +68,17 @@ function previousExpectedFire(cron, now) {
49
68
  * Returns null when neither is available, which leaves the routine unfloored
50
69
  * (previous behaviour) rather than silently skipping it.
51
70
  */
52
- export function routineEffectiveStart(job) {
71
+ export function routineEffectiveStart(job, now = new Date()) {
53
72
  if (job.createdAt) {
54
73
  const stamped = new Date(job.createdAt);
55
- if (!isNaN(stamped.getTime()))
56
- return stamped;
74
+ // Clamp a future stamp (clock skew, a hand-edited year) to now. Left
75
+ // unclamped it sits after every possible expected fire, so the routine can
76
+ // never be flagged overdue until wall-clock time catches up.
77
+ if (!isNaN(stamped.getTime())) {
78
+ return stamped.getTime() > now.getTime() ? now : stamped;
79
+ }
57
80
  }
58
- const path = getJobPath(job.name);
81
+ const path = resolveJobFilePath(job.name);
59
82
  if (!path)
60
83
  return null;
61
84
  try {
@@ -70,7 +93,19 @@ export function routineEffectiveStart(job) {
70
93
  export function detectOverdueJobs(now = new Date()) {
71
94
  const overdue = [];
72
95
  for (const job of listJobs()) {
73
- if (!job.enabled || job.runOnce)
96
+ if (!job.enabled)
97
+ continue;
98
+ // One-shot: fires at most once, so a missed slot is not a backlog to replay.
99
+ // Use the same predicate the scheduler does — the raw `runOnce` flag alone
100
+ // missed a one-shot-LIKE schedule (a fixed minute/hour/day/month) that never
101
+ // carried the flag.
102
+ if (isOneShotRoutine(job))
103
+ continue;
104
+ // Past its configured end: catch-up must not resurrect a routine the author
105
+ // already retired. The scheduler only auto-disables lazily, inside a live
106
+ // cron tick, so a routine whose endAt elapsed while the daemon was down is
107
+ // still enabled on disk when the catch-up pass runs.
108
+ if (isPastEndAt(job, now))
74
109
  continue;
75
110
  // Trigger-only jobs (no cron schedule) never have an expected fire time.
76
111
  if (!job.schedule)
@@ -99,7 +134,7 @@ export function detectOverdueJobs(now = new Date()) {
99
134
  // a miss. Without this, any newly created routine on a daily/weekly cron is
100
135
  // instantly "overdue" for the previous occurrence — and with auto-catchup
101
136
  // that means `agents routines add` runs the routine once, immediately.
102
- const start = routineEffectiveStart(job);
137
+ const start = routineEffectiveStart(job, now);
103
138
  if (start && expected.getTime() < start.getTime())
104
139
  continue;
105
140
  const latest = getLatestRun(job.name);
@@ -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. */