@phnx-labs/agents-cli 1.20.93 → 1.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +290 -0
  2. package/README.md +4 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/beta.js +3 -5
  5. package/dist/commands/doctor.js +23 -0
  6. package/dist/commands/exec.js +13 -0
  7. package/dist/commands/feed.js +37 -23
  8. package/dist/commands/mailboxes.js +39 -1
  9. package/dist/commands/message.js +12 -1
  10. package/dist/commands/monitors.js +8 -6
  11. package/dist/commands/packages.js +14 -5
  12. package/dist/commands/projects.d.ts +28 -0
  13. package/dist/commands/projects.js +173 -47
  14. package/dist/commands/routines.js +27 -1
  15. package/dist/commands/rules.js +6 -0
  16. package/dist/commands/sessions.d.ts +17 -0
  17. package/dist/commands/sessions.js +16 -8
  18. package/dist/commands/setup-browser.js +5 -1
  19. package/dist/commands/setup-fleet.js +5 -0
  20. package/dist/commands/setup-preferences.d.ts +53 -0
  21. package/dist/commands/setup-preferences.js +142 -0
  22. package/dist/commands/setup.js +8 -9
  23. package/dist/commands/ssh.js +220 -10
  24. package/dist/commands/sync.d.ts +2 -2
  25. package/dist/commands/sync.js +4 -9
  26. package/dist/commands/watchdog.js +26 -3
  27. package/dist/index.js +22 -6
  28. package/dist/lib/activity.d.ts +13 -0
  29. package/dist/lib/activity.js +22 -2
  30. package/dist/lib/auto-dispatch.d.ts +6 -1
  31. package/dist/lib/auto-dispatch.js +7 -2
  32. package/dist/lib/beta.d.ts +1 -1
  33. package/dist/lib/beta.js +1 -1
  34. package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
  35. package/dist/lib/channels/resolve.d.ts +18 -1
  36. package/dist/lib/channels/resolve.js +15 -5
  37. package/dist/lib/daemon.d.ts +13 -0
  38. package/dist/lib/daemon.js +80 -60
  39. package/dist/lib/device-config.d.ts +82 -0
  40. package/dist/lib/device-config.js +296 -0
  41. package/dist/lib/event-provenance.d.ts +19 -0
  42. package/dist/lib/event-provenance.js +48 -0
  43. package/dist/lib/events.d.ts +2 -2
  44. package/dist/lib/events.js +4 -53
  45. package/dist/lib/feed-broadcast.d.ts +41 -5
  46. package/dist/lib/feed-broadcast.js +110 -12
  47. package/dist/lib/feed-post.d.ts +16 -2
  48. package/dist/lib/feed-post.js +35 -7
  49. package/dist/lib/feed.d.ts +1 -1
  50. package/dist/lib/feed.js +3 -1
  51. package/dist/lib/git.d.ts +13 -2
  52. package/dist/lib/git.js +38 -6
  53. package/dist/lib/hooks.d.ts +16 -1
  54. package/dist/lib/hooks.js +61 -1
  55. package/dist/lib/linear-project-counts.d.ts +72 -5
  56. package/dist/lib/linear-project-counts.js +90 -6
  57. package/dist/lib/linear-projects.d.ts +12 -0
  58. package/dist/lib/linear-projects.js +30 -0
  59. package/dist/lib/mailbox-gc.js +30 -7
  60. package/dist/lib/mailbox.d.ts +14 -1
  61. package/dist/lib/mailbox.js +35 -3
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  63. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  64. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  65. package/dist/lib/menubar/install-menubar.js +35 -44
  66. package/dist/lib/migrate.js +1 -1
  67. package/dist/lib/models.js +21 -11
  68. package/dist/lib/monitors/config.d.ts +1 -1
  69. package/dist/lib/monitors/dispatch.d.ts +6 -2
  70. package/dist/lib/monitors/dispatch.js +10 -15
  71. package/dist/lib/notify.d.ts +42 -6
  72. package/dist/lib/notify.js +41 -32
  73. package/dist/lib/overdue.d.ts +1 -1
  74. package/dist/lib/overdue.js +54 -19
  75. package/dist/lib/project-import.d.ts +96 -0
  76. package/dist/lib/project-import.js +171 -0
  77. package/dist/lib/remote-agents-json.d.ts +14 -1
  78. package/dist/lib/remote-agents-json.js +21 -3
  79. package/dist/lib/routines.d.ts +53 -0
  80. package/dist/lib/routines.js +105 -4
  81. package/dist/lib/rules/run-sync.d.ts +18 -0
  82. package/dist/lib/rules/run-sync.js +92 -0
  83. package/dist/lib/scheduler.js +12 -2
  84. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  85. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  86. package/dist/lib/secrets/index.d.ts +4 -5
  87. package/dist/lib/secrets/index.js +4 -5
  88. package/dist/lib/session/active.d.ts +16 -2
  89. package/dist/lib/session/active.js +8 -4
  90. package/dist/lib/session/bundle.js +5 -1
  91. package/dist/lib/session/remote-list.d.ts +1 -15
  92. package/dist/lib/session/remote-list.js +22 -111
  93. package/dist/lib/session/sync/config.d.ts +13 -5
  94. package/dist/lib/session/sync/config.js +21 -13
  95. package/dist/lib/startup/command-registry.d.ts +0 -2
  96. package/dist/lib/startup/command-registry.js +1 -5
  97. package/dist/lib/state.d.ts +7 -2
  98. package/dist/lib/state.js +85 -10
  99. package/dist/lib/sync-umbrella.d.ts +11 -23
  100. package/dist/lib/sync-umbrella.js +14 -45
  101. package/dist/lib/teams/agents.d.ts +35 -3
  102. package/dist/lib/teams/agents.js +52 -8
  103. package/dist/lib/teams/scheduler.d.ts +29 -2
  104. package/dist/lib/teams/scheduler.js +60 -16
  105. package/dist/lib/types.d.ts +23 -1
  106. package/package.json +1 -2
  107. package/dist/commands/drive.d.ts +0 -10
  108. package/dist/commands/drive.js +0 -183
  109. package/dist/commands/hq.d.ts +0 -2
  110. package/dist/commands/hq.js +0 -58
  111. package/dist/commands/sessions-sync.d.ts +0 -17
  112. package/dist/commands/sessions-sync.js +0 -135
  113. package/dist/commands/sync-provision.d.ts +0 -23
  114. package/dist/commands/sync-provision.js +0 -107
  115. package/dist/lib/drive-sync.d.ts +0 -45
  116. package/dist/lib/drive-sync.js +0 -238
  117. package/dist/lib/hq/floor.d.ts +0 -87
  118. package/dist/lib/hq/floor.js +0 -243
  119. package/dist/lib/session/sync/crdt.d.ts +0 -44
  120. package/dist/lib/session/sync/crdt.js +0 -119
  121. package/dist/lib/session/sync/manifest.d.ts +0 -62
  122. package/dist/lib/session/sync/manifest.js +0 -100
  123. package/dist/lib/session/sync/provision.d.ts +0 -49
  124. package/dist/lib/session/sync/provision.js +0 -91
  125. package/dist/lib/session/sync/r2.d.ts +0 -32
  126. package/dist/lib/session/sync/r2.js +0 -121
  127. package/dist/lib/session/sync/sync.d.ts +0 -106
  128. package/dist/lib/session/sync/sync.js +0 -374
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { spawn } from 'child_process';
3
3
  import { die } from '../lib/format.js';
4
+ import { parseDuration } from '../lib/hooks/cache.js';
4
5
  import { getActiveSessions } from '../lib/session/active.js';
5
6
  import { getTaskById, updateTaskStatus } from '../lib/cloud/store.js';
6
7
  import { resolveProvider } from '../lib/cloud/registry.js';
@@ -53,6 +54,7 @@ async function deliverViaMailbox(mailboxId, text, block, opts) {
53
54
  text,
54
55
  from: opts.from,
55
56
  blockId: block?.blockId,
57
+ ttlSeconds: opts.ttlSeconds,
56
58
  });
57
59
  if (block) {
58
60
  recordMessageReceipt(block.blockId, {
@@ -116,10 +118,19 @@ export function registerMessageCommand(program) {
116
118
  .option('--from <who>', 'Label recorded as the sender of this message')
117
119
  .option('--as <operator>', 'Verified operator id answering a high-consequence block')
118
120
  .option('--surface <surface>', 'Surface that is sending this answer (feed, terminal, etc.)', 'cli')
121
+ .option('--ttl <dur>', 'Delivery TTL if the message is not consumed (e.g. 30m, 1h, 24h); 0 disables expiry')
119
122
  .action(async (target, text, opts) => {
120
123
  if (!target.trim()) {
121
124
  die('Target must be a session/agent id or cloud task id. Run `agents sessions --active` to list running agents.');
122
125
  }
126
+ let ttlSeconds;
127
+ if (opts.ttl !== undefined) {
128
+ const parsed = parseDuration(opts.ttl);
129
+ if (parsed == null) {
130
+ die(`Invalid --ttl ${JSON.stringify(opts.ttl)}: expected a duration like 30m, 1h, 24h, or 0.`);
131
+ }
132
+ ttlSeconds = parsed;
133
+ }
123
134
  const sessions = await getActiveSessions();
124
135
  const res = resolveMessageTarget(target, sessions, (id) => getTaskById(id) != null);
125
136
  switch (res.kind) {
@@ -152,7 +163,7 @@ export function registerMessageCommand(program) {
152
163
  // First-answer-wins for any path that closes an open block.
153
164
  claimBlockAnswer(block, opts);
154
165
  if (route.kind === 'mailbox') {
155
- await deliverViaMailbox(res.id, text, block, opts);
166
+ await deliverViaMailbox(res.id, text, block, { from: opts.from, ttlSeconds });
156
167
  return;
157
168
  }
158
169
  if (route.kind === 'tmux' || route.kind === 'iterm' || route.kind === 'pty') {
@@ -57,7 +57,7 @@ function actionLabel(action) {
57
57
  case 'routine':
58
58
  return `routine ${action.routine ?? ''}`;
59
59
  case 'notify':
60
- return `notify ${action.notifyChannel ?? 'telegram'}`;
60
+ return `notify ${action.notifyChannel ?? 'owner'}`;
61
61
  case 'webhook-out':
62
62
  return `webhook-out ${action.url ?? ''}`;
63
63
  default:
@@ -195,9 +195,11 @@ function buildAction(options) {
195
195
  if (options.routine)
196
196
  chosen.push({ type: 'routine', routine: options.routine });
197
197
  if (options.notify !== undefined) {
198
- // --notify may be a bare flag (true) or carry a channel string.
199
- const channel = typeof options.notify === 'string' ? options.notify : 'telegram';
200
- chosen.push({ type: 'notify', notifyChannel: channel });
198
+ // --notify may be a bare flag (notify the owner) or carry a channel that
199
+ // overrides notify.owner.channel. Left unset, the send resolves the owner
200
+ // channel + target from notify.owner in agents.yaml (one source of truth).
201
+ const channel = typeof options.notify === 'string' ? options.notify : undefined;
202
+ chosen.push({ type: 'notify', ...(channel ? { notifyChannel: channel } : {}) });
201
203
  }
202
204
  if (options.webhookOut)
203
205
  chosen.push({ type: 'webhook-out', url: options.webhookOut });
@@ -310,7 +312,7 @@ export function registerMonitorsCommands(program) {
310
312
  .option('--effort <effort>', 'Reasoning effort for --run: low | medium | high | xhigh | max | auto')
311
313
  .option('--action-timeout <t>', 'Kill the --run action if it runs longer than this (e.g. 10m)')
312
314
  .option('--routine <name>', 'Fire an existing routine on change')
313
- .option('--notify [channel]', 'Send a notification (default channel: telegram)')
315
+ .option('--notify [channel]', 'Notify the owner (notify.owner); [channel] overrides the owner channel')
314
316
  .option('--webhook-out <url>', 'POST the event to this URL')
315
317
  // PLACEMENT / hygiene
316
318
  .option('--device <name>', 'OWNER device — the single machine that evaluates + fires (exactly-once)')
@@ -573,7 +575,7 @@ export function registerMonitorsCommands(program) {
573
575
  name,
574
576
  source: { type: 'poll', command: 'echo hello', interval: '1m' },
575
577
  condition: { mode: 'on-change' },
576
- action: { type: 'notify', notifyChannel: 'telegram' },
578
+ action: { type: 'notify' },
577
579
  });
578
580
  fs.writeFileSync(monitorPath, template, 'utf-8');
579
581
  console.log(chalk.gray(`Created new monitor file: ${monitorPath}`));
@@ -14,7 +14,7 @@ import { AGENTS, getAllCliStates, agentLabel, } from '../lib/agents.js';
14
14
  import { capableAgents } from '../lib/capabilities.js';
15
15
  import { DEFAULT_REGISTRIES } from '../lib/types.js';
16
16
  import { getRegistries, setRegistry, removeRegistry, search as searchRegistries, resolvePackage, validatedNpmSpec, validatedPyPISpec, buildSkillIndex, verifySkillIntegrity, parseOwnerRepoFromRemote, } from '../lib/registry.js';
17
- import { cloneRepo, commitAndPush, getRemoteUrl, isGitRepo } from '../lib/git.js';
17
+ import { cloneRepo, commitAndPush, getCurrentBranch, getRemoteUrl, isGitRepo } from '../lib/git.js';
18
18
  import { discoverCommands, resolveCommandSource, installCommand, installCommandCentrally, } from '../lib/commands.js';
19
19
  import { discoverSkillsFromRepo, installSkill, installSkillCentrally, } from '../lib/skills.js';
20
20
  import { discoverHooksFromRepo, installHooks, installHooksCentrally, } from '../lib/hooks.js';
@@ -335,7 +335,7 @@ When to use:
335
335
  .description('Generate a skills-index.json for a git repo and push it, making its skills discoverable via agents search/install')
336
336
  .option('--repo <alias>', 'Publish an extra repo added via `agents repo add` (default: your ~/.agents repo)')
337
337
  .option('--name <name>', 'Registry name to suggest in the output (default: the repo name)')
338
- .option('--branch <branch>', 'Branch the raw URL should reference', 'main')
338
+ .option('--branch <branch>', 'Branch to push the index to and reference in the raw URL (default: the repo\'s current branch)')
339
339
  .option('--dry-run', 'Write the index and print the URL without committing or pushing')
340
340
  .addHelpText('after', `
341
341
  Publish walks a repo's skills/ directory, records a sha256 of every SKILL.md,
@@ -398,20 +398,29 @@ the sha256 in the index and abort on mismatch.
398
398
  for (const s of index.skills) {
399
399
  console.log(` ${chalk.cyan(s.name)} ${chalk.gray(`sha256:${s.sha256?.slice(0, 12)}…`)}`);
400
400
  }
401
+ // The branch the URL references must be the branch the index actually
402
+ // lands on. In a real push that's whatever commitAndPush reports back
403
+ // (the requested --branch, else the checked-out branch); in a dry run we
404
+ // resolve it the same way without pushing.
405
+ let urlBranch;
401
406
  if (options.dryRun) {
407
+ urlBranch = options.branch || (await getCurrentBranch(repoDir));
402
408
  console.log(chalk.gray(`\nDry run — wrote ${indexPath} but did not commit or push.`));
403
409
  }
404
410
  else {
405
411
  const pushSpinner = ora('Committing and pushing skills-index.json...').start();
406
- const result = await commitAndPush(repoDir, 'chore: update skills-index.json (agents publish)');
412
+ const result = await commitAndPush(repoDir, 'chore: update skills-index.json (agents publish)', options.branch);
407
413
  if (!result.success) {
408
414
  pushSpinner.fail(`Push failed: ${result.error}`);
409
415
  console.log(chalk.gray('The index was written locally — commit and push it manually to publish.'));
410
416
  process.exit(1);
411
417
  }
412
- pushSpinner.succeed('Pushed skills-index.json');
418
+ // commitAndPush always reports the pushed branch on success; the `??`
419
+ // only satisfies the optional return type, it is not a behavior path.
420
+ urlBranch = result.branch ?? (await getCurrentBranch(repoDir));
421
+ pushSpinner.succeed(`Pushed skills-index.json to ${urlBranch}`);
413
422
  }
414
- const rawUrl = `https://raw.githubusercontent.com/${repoSlug}/${options.branch}/skills-index.json`;
423
+ const rawUrl = `https://raw.githubusercontent.com/${repoSlug}/${urlBranch}/skills-index.json`;
415
424
  const registryName = options.name || repoSlug.split('/')[1] || 'my-skills';
416
425
  console.log(chalk.bold('\nPublished. Share these with anyone who wants your skills:\n'));
417
426
  console.log(chalk.gray(' Index URL:'));
@@ -9,6 +9,7 @@
9
9
  * lifecycle state, plan completion, open PRs, and tickets in flight.
10
10
  */
11
11
  import type { Command } from 'commander';
12
+ import { type LinearMilestone } from '../lib/linear-project-counts.js';
12
13
  /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
13
14
  export declare const PROJECTS_NO_FANOUT_ENV = "AGENTS_PROJECTS_LOCAL";
14
15
  /**
@@ -19,4 +20,31 @@ export declare const PROJECTS_NO_FANOUT_ENV = "AGENTS_PROJECTS_LOCAL";
19
20
  * string when everything answered.
20
21
  */
21
22
  export declare function formatFleetSkippedNote(skipped: string[]): string;
23
+ /** One `projects list` row, pre-render. */
24
+ export interface ProjectListRow {
25
+ name: string;
26
+ path: string;
27
+ repo: string;
28
+ }
29
+ /**
30
+ * Column widths for `projects list`, sized to the rows actually being printed.
31
+ * Fixed padding was the bug: home-relative roots run past 50 characters, so a
32
+ * hardcoded 32 pushed the repo column off its gridline on every long path.
33
+ * Paths longer than {@link LIST_PATH_MAX} truncate rather than widen the table.
34
+ */
35
+ export declare function computeProjectListWidths(rows: ProjectListRow[]): {
36
+ name: number;
37
+ path: number;
38
+ repo: number;
39
+ };
40
+ /**
41
+ * A milestone's target date as a person would say it — "due tomorrow",
42
+ * "overdue by 3 days", "due Aug 21" — never a raw `2026-08-21` or a duration in
43
+ * hours. Linear stores a calendar date with no timezone, so both sides are
44
+ * compared at LOCAL midnight; parsing `YYYY-MM-DD` with `new Date(str)` would
45
+ * read it as UTC and shift the answer by a day for anyone west of Greenwich.
46
+ */
47
+ export declare function formatMilestoneDue(targetDate: string, nowMs: number): string | undefined;
48
+ /** The `next` card line: what this project is due to hit, and how far along it is. */
49
+ export declare function formatNextMilestone(ms: LinearMilestone, nowMs: number): string;
22
50
  export declare function registerProjectsCommands(program: Command): void;
@@ -10,12 +10,14 @@
10
10
  */
11
11
  import chalk from 'chalk';
12
12
  import * as fs from 'fs';
13
+ import * as path from 'path';
13
14
  import { execFileSync, spawnSync } from 'child_process';
14
15
  import { betaEnableHint, isBetaEnabled } from '../lib/beta.js';
15
16
  import { setHelpSections } from '../lib/help.js';
16
17
  import { getMainRepoRoot } from '../lib/git.js';
17
18
  import { parseOwnerRepoFromRemote } from '../lib/registry.js';
18
- import { toHomeRelative } from '../lib/project-root.js';
19
+ import { truncate } from '../lib/format.js';
20
+ import { expandLocalHome, getProjectRoot, toHomeRelative } from '../lib/project-root.js';
19
21
  import { machineId } from '../lib/machine-id.js';
20
22
  import { getActiveSessions } from '../lib/session/active.js';
21
23
  import { gatherRemoteActive } from '../lib/session/remote-active.js';
@@ -26,6 +28,7 @@ import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, pro
26
28
  import { rollupSessionsByProject, planPct, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
27
29
  import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
28
30
  import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
31
+ import { buildFactoryImportCandidates, buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
29
32
  /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
30
33
  export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
31
34
  /** Max peers named in the skipped note before the rest collapse to `+N`. */
@@ -63,6 +66,135 @@ function originSlug(cwd) {
63
66
  return undefined;
64
67
  }
65
68
  }
69
+ /**
70
+ * Read the Factory registry off disk and plan its import. Every read failure is
71
+ * fatal and named — an unreadable or malformed registry must not read as "0
72
+ * projects to import".
73
+ */
74
+ function runFactoryImport(existing, opts) {
75
+ const src = factoryProjectsPath();
76
+ let rawText;
77
+ try {
78
+ rawText = fs.readFileSync(src, 'utf8');
79
+ }
80
+ catch {
81
+ console.error(chalk.red(`No Factory registry at ${src}`));
82
+ process.exit(1);
83
+ }
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(rawText);
87
+ }
88
+ catch {
89
+ console.error(chalk.red(`Factory registry at ${src} is not valid JSON`));
90
+ process.exit(1);
91
+ }
92
+ const rows = Array.isArray(parsed)
93
+ ? parsed
94
+ : Array.isArray(parsed?.projects)
95
+ ? parsed.projects
96
+ : [];
97
+ return buildFactoryImportCandidates(rows, existing, opts);
98
+ }
99
+ /**
100
+ * List the workspace's Linear projects and plan their import, binding each to a
101
+ * local checkout under the configured projects root when the names match
102
+ * exactly. A missing / logged-out `linear` CLI is loud (this is an explicit user
103
+ * command), and nothing is written before the whole plan is built.
104
+ */
105
+ function runLinearImport(existing, opts) {
106
+ let list;
107
+ try {
108
+ list = listLinearProjects();
109
+ }
110
+ catch (e) {
111
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
112
+ process.exit(1);
113
+ }
114
+ // No projects root configured (or unreadable) → no local matching; every def
115
+ // still imports, carrying its Linear link and nothing it can't prove.
116
+ const rootAbs = getProjectRoot() ? expandLocalHome(getProjectRoot()) : undefined;
117
+ let localDirs = [];
118
+ if (rootAbs) {
119
+ try {
120
+ localDirs = fs
121
+ .readdirSync(rootAbs, { withFileTypes: true })
122
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
123
+ .map((d) => d.name);
124
+ }
125
+ catch {
126
+ localDirs = [];
127
+ }
128
+ }
129
+ return buildLinearImportCandidates(list, existing, {
130
+ localDirs,
131
+ resolveRoot: (dir) => (rootAbs ? toHomeRelative(path.join(rootAbs, dir)) : undefined),
132
+ resolveOrigin: (dir) => (rootAbs ? originSlug(path.join(rootAbs, dir)) : undefined),
133
+ }, opts);
134
+ }
135
+ /** Longest path a `list` row shows before it truncates. */
136
+ const LIST_PATH_MAX = 48;
137
+ /**
138
+ * Column widths for `projects list`, sized to the rows actually being printed.
139
+ * Fixed padding was the bug: home-relative roots run past 50 characters, so a
140
+ * hardcoded 32 pushed the repo column off its gridline on every long path.
141
+ * Paths longer than {@link LIST_PATH_MAX} truncate rather than widen the table.
142
+ */
143
+ export function computeProjectListWidths(rows) {
144
+ const widest = (pick, cap) => Math.min(cap, rows.reduce((w, r) => Math.max(w, pick(r).length), 0));
145
+ return {
146
+ name: widest((r) => r.name, 64),
147
+ path: widest((r) => r.path, LIST_PATH_MAX),
148
+ repo: widest((r) => r.repo, 64),
149
+ };
150
+ }
151
+ /**
152
+ * A milestone's target date as a person would say it — "due tomorrow",
153
+ * "overdue by 3 days", "due Aug 21" — never a raw `2026-08-21` or a duration in
154
+ * hours. Linear stores a calendar date with no timezone, so both sides are
155
+ * compared at LOCAL midnight; parsing `YYYY-MM-DD` with `new Date(str)` would
156
+ * read it as UTC and shift the answer by a day for anyone west of Greenwich.
157
+ */
158
+ export function formatMilestoneDue(targetDate, nowMs) {
159
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(targetDate.trim());
160
+ if (!m)
161
+ return undefined;
162
+ const due = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
163
+ if (Number.isNaN(due.getTime()))
164
+ return undefined;
165
+ const now = new Date(nowMs);
166
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
167
+ const days = Math.round((due.getTime() - today.getTime()) / 86_400_000);
168
+ if (days === 0)
169
+ return 'due today';
170
+ if (days === 1)
171
+ return 'due tomorrow';
172
+ if (days === -1)
173
+ return 'overdue by a day';
174
+ if (days < 0)
175
+ return `overdue by ${-days} days`;
176
+ if (days <= 14)
177
+ return `due in ${days} days`;
178
+ const sameYear = due.getFullYear() === today.getFullYear();
179
+ const label = due.toLocaleDateString('en-US', {
180
+ month: 'short',
181
+ day: 'numeric',
182
+ ...(sameYear ? {} : { year: 'numeric' }),
183
+ });
184
+ return `due ${label}`;
185
+ }
186
+ /** The `next` card line: what this project is due to hit, and how far along it is. */
187
+ export function formatNextMilestone(ms, nowMs) {
188
+ const parts = [chalk.bold(ms.name)];
189
+ // A milestone with nothing filed under it yet has no progress to report —
190
+ // `0/0` is noise, not information.
191
+ if (ms.total > 0)
192
+ parts.push(`${ms.done}/${ms.total}`);
193
+ const due = ms.targetDate ? formatMilestoneDue(ms.targetDate, nowMs) : undefined;
194
+ if (due)
195
+ parts.push(due.startsWith('overdue') ? chalk.yellow(due) : chalk.dim(due));
196
+ return parts.join(chalk.dim(' · '));
197
+ }
66
198
  function statusBar(r) {
67
199
  const parts = [];
68
200
  const push = (n, label, color) => {
@@ -80,7 +212,7 @@ function statusBar(r) {
80
212
  parts.push(chalk.gray(`+${r.agents - shown} other`));
81
213
  return parts.join(' · ') || chalk.gray('no live agents');
82
214
  }
83
- function renderCard(def, r, remote, fleet, linear) {
215
+ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
84
216
  const agents = r?.agents ?? 0;
85
217
  const pct = r ? planPct(r.plan) : undefined;
86
218
  const planStr = pct === undefined ? '' : ` · ${chalk.cyan(`${pct}% plan`)}`;
@@ -104,6 +236,9 @@ function renderCard(def, r, remote, fleet, linear) {
104
236
  if (linear) {
105
237
  console.log(` ${chalk.dim('linear')} ${linear.done}/${linear.total}${linear.truncated ? '+' : ''} done · ${linear.inProgress} in progress`);
106
238
  }
239
+ if (linear?.nextMilestone) {
240
+ console.log(` ${chalk.dim('next')} ${formatNextMilestone(linear.nextMilestone, nowMs)}`);
241
+ }
107
242
  if (r && r.tickets.length) {
108
243
  console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
109
244
  }
@@ -138,6 +273,7 @@ export function registerProjectsCommands(program) {
138
273
  .description('Named multi-repo projects with a progress rollup.');
139
274
  setHelpSections(projects, {
140
275
  examples: `
276
+ agents projects import --from-linear # the projects you actually track
141
277
  agents projects add rush --repo phnx-labs/rush --path apps/web
142
278
  agents projects list
143
279
  agents projects status # progress card for every project
@@ -149,6 +285,11 @@ export function registerProjectsCommands(program) {
149
285
  notes: `
150
286
  Definitions are hand-editable YAML in ~/.agents/projects/ and sync across
151
287
  machines with 'agents push/pull'. Enable with: agents beta enable projects.
288
+
289
+ 'import --from-factory' reads Factory's auto-detected registry, which
290
+ guesses from checkouts on disk — it imports only 'high' confidence rows by
291
+ default. Widen with --min-confidence medium or --all, and drop a bad guess
292
+ with 'agents projects rm <name>'.
152
293
  `,
153
294
  });
154
295
  projects.hook('preAction', (_thisCommand, actionCommand) => {
@@ -180,10 +321,16 @@ export function registerProjectsCommands(program) {
180
321
  return;
181
322
  }
182
323
  const roll = rollupSessionsByProject(defs, await getActiveSessions());
183
- for (const d of defs) {
324
+ const rows = defs.map((d) => ({
325
+ name: d.name,
326
+ path: truncate(d.root ?? d.defaultPath ?? '', LIST_PATH_MAX),
327
+ repo: d.repo ?? d.repos?.[0]?.slug ?? '',
328
+ }));
329
+ const w = computeProjectListWidths(rows);
330
+ for (const [i, d] of defs.entries()) {
184
331
  const agents = roll.get(d.name)?.agents ?? 0;
185
- const repo = d.repo ?? d.repos?.[0]?.slug ?? '';
186
- console.log(` ${chalk.bold(d.name.padEnd(16))} ${chalk.dim((d.root ?? d.defaultPath ?? '').padEnd(32))} ${chalk.cyan(repo.padEnd(24))} ${agents} agents`);
332
+ const row = rows[i];
333
+ console.log(` ${chalk.bold(row.name.padEnd(w.name))} ${chalk.dim(row.path.padEnd(w.path))} ${chalk.cyan(row.repo.padEnd(w.repo))} ${agents} agents`);
187
334
  }
188
335
  });
189
336
  // ---- add ----
@@ -384,7 +531,7 @@ export function registerProjectsCommands(program) {
384
531
  return;
385
532
  }
386
533
  for (const d of defs) {
387
- renderCard(d, roll.get(d.name), remote.get(d.name), opts.fleet ? fleetFor(d) : undefined, linear.get(d.name));
534
+ renderCard(d, roll.get(d.name), remote.get(d.name), opts.fleet ? fleetFor(d) : undefined, linear.get(d.name), nowMs);
388
535
  }
389
536
  if (fleetSkipped.length > 0)
390
537
  process.stdout.write(formatFleetSkippedNote(fleetSkipped));
@@ -402,54 +549,33 @@ export function registerProjectsCommands(program) {
402
549
  // ---- import ----
403
550
  projects
404
551
  .command('import')
405
- .description('Absorb the Factory project registry (~/.agents/factory/projects.json) into YAML definitions.')
406
- .requiredOption('--from-factory', 'Import from the Factory projects.json registry')
552
+ .description('Import project definitions from Linear (preferred) or the Factory auto-detection registry.')
553
+ .option('--from-linear', 'Import the workspace\'s Linear projects (via the `linear` CLI)')
554
+ .option('--from-factory', 'Import the Factory registry (~/.agents/factory/projects.json)')
555
+ .option('--min-confidence <level>', '--from-factory only: lowest detection confidence to import — low|medium|high (default: high)')
556
+ .option('--all', '--from-factory only: import every row regardless of confidence')
407
557
  .option('--force', 'Overwrite existing definitions')
408
- .action((opts) => {
409
- const src = factoryProjectsPath();
410
- let rawText;
411
- try {
412
- rawText = fs.readFileSync(src, 'utf8');
413
- }
414
- catch {
415
- console.error(chalk.red(`No Factory registry at ${src}`));
416
- process.exit(1);
417
- }
418
- let rows;
558
+ .action((raw) => {
559
+ let opts;
419
560
  try {
420
- rows = JSON.parse(rawText);
561
+ opts = validateImportOpts(raw);
421
562
  }
422
- catch {
423
- console.error(chalk.red(`Factory registry at ${src} is not valid JSON`));
563
+ catch (e) {
564
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
424
565
  process.exit(1);
425
566
  }
426
- const list = Array.isArray(rows) ? rows : Array.isArray(rows.projects) ? rows.projects : [];
427
- let created = 0;
428
- let skipped = 0;
429
- for (const raw of list) {
430
- if (!raw || typeof raw !== 'object')
431
- continue;
432
- const o = raw;
433
- const name = typeof o.name === 'string' ? o.name : undefined;
434
- if (!name || !isSafeProjectName(name)) {
435
- skipped++;
436
- continue;
437
- }
438
- if (loadProjectDef(name) && !opts.force) {
439
- skipped++;
440
- continue;
441
- }
442
- const def = { name };
443
- if (typeof o.path === 'string')
444
- def.root = toHomeRelative(o.path);
445
- if (typeof o.repoSlug === 'string')
446
- def.repo = o.repoSlug;
447
- if (typeof o.linearProjectId === 'string')
448
- def.linear = { projectId: o.linearProjectId };
567
+ const existing = new Map(listProjectDefs().map((d) => [d.name, d]));
568
+ const result = opts.source === 'linear' ? runLinearImport(existing, opts) : runFactoryImport(existing, opts);
569
+ for (const def of result.defs)
449
570
  writeProjectDef(def);
450
- created++;
571
+ const n = result.defs.length;
572
+ const s = result.skipped.length;
573
+ console.log(chalk.green(`Imported ${n} project${n === 1 ? '' : 's'}${s ? chalk.gray(` (${s} skipped)`) : ''}`));
574
+ for (const skip of result.skipped)
575
+ console.log(chalk.gray(` skip ${skip.name}: ${skip.reason}`));
576
+ if (opts.source === 'factory' && s > 0 && opts.minConfidence === 'high') {
577
+ console.log(chalk.gray(' (widen with --min-confidence medium or --all)'));
451
578
  }
452
- console.log(chalk.green(`Imported ${created} project${created === 1 ? '' : 's'}${skipped ? chalk.gray(` (${skipped} skipped)`) : ''}`));
453
579
  });
454
580
  // ---- link ----
455
581
  projects
@@ -11,6 +11,7 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as yaml from 'yaml';
13
13
  import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonLog, getDaemonStatus, } from '../lib/daemon.js';
14
+ import { assertSchedulerEnabled } from '../lib/device-config.js';
14
15
  import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
15
16
  import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
16
17
  import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
@@ -295,9 +296,21 @@ function parseRoutineTrigger(options) {
295
296
  /**
296
297
  * Start or reload the background scheduler so newly-added jobs fire on time.
297
298
  * `quiet` suppresses human status lines for JSON callers.
299
+ *
300
+ * When this device has `scheduler.enabled=false` the auto-start is skipped with
301
+ * the stated reason (the add itself already succeeded — the job is config and
302
+ * stays valid fleet-wide); the refusal message names the setting and the fix.
298
303
  */
299
304
  function ensureSchedulerRunning(opts = {}) {
300
305
  const log = opts.stderr ? console.error : console.log;
306
+ try {
307
+ assertSchedulerEnabled();
308
+ }
309
+ catch (err) {
310
+ // Loud stated skip, on stderr so --json stdout stays clean.
311
+ console.error(chalk.yellow(err.message));
312
+ return;
313
+ }
301
314
  if (isDaemonRunning()) {
302
315
  signalDaemonReload();
303
316
  if (!opts.quiet)
@@ -1480,9 +1493,22 @@ export function registerRoutinesCommands(program) {
1480
1493
  .command('start')
1481
1494
  .description('Start the background scheduler. Usually unnecessary — it auto-starts when you add your first routine.')
1482
1495
  .action(() => {
1496
+ try {
1497
+ // A manual start on a scheduler-disabled device refuses with the same
1498
+ // message the auto-start surfaces give.
1499
+ assertSchedulerEnabled();
1500
+ }
1501
+ catch (err) {
1502
+ console.error(chalk.red(err.message));
1503
+ process.exit(1);
1504
+ }
1483
1505
  const result = startDaemon();
1484
1506
  if (result.method === 'already-running') {
1485
- console.log(chalk.yellow(`Scheduler already running (PID: ${result.pid})`));
1507
+ // Signal a reload even here: if the daemon booted while this device had
1508
+ // scheduler.enabled=false, the reload re-evaluates the gate and boots
1509
+ // the scheduler — a manual start heals a scheduler-less daemon.
1510
+ signalDaemonReload();
1511
+ console.log(chalk.yellow(`Scheduler already running (PID: ${result.pid}) — reloaded`));
1486
1512
  }
1487
1513
  else if (result.pid) {
1488
1514
  console.log(chalk.green(`Scheduler started (PID: ${result.pid})`));
@@ -512,6 +512,12 @@ Examples:
512
512
  .addHelpText('after', `
513
513
  Targets are <agent>@<version>. Use 'default' for the alias of the global default version.
514
514
 
515
+ This re-syncs the version home immediately, AND the choice sticks: every later
516
+ 'agents run' for this agent+version re-applies the active preset before launch
517
+ (skip-fast when nothing changed), so a preset set here never goes stale even if
518
+ something changes it again without running 'switch' — you don't need to re-run
519
+ 'switch' or 'sync' by hand before the next launch.
520
+
515
521
  Examples:
516
522
  # Persist the cautious preset for claude 2.1.111
517
523
  agents rules switch claude@2.1.111 --preset cautious
@@ -315,6 +315,13 @@ export declare function isBareBrowserListing(options: SessionsOptions, query: st
315
315
  * `runSessionBrowser` picker cannot represent.
316
316
  */
317
317
  export declare function hasNoBrowserDisqualifyingFlags(options: SessionsOptions, query: string | undefined): boolean;
318
+ /** Resolve a session by id/query globally and print its compact preview (no pager).
319
+ * Backs `--preview` — the fast path for the "peek before resume" hot loop. */
320
+ export declare function renderSessionPreview(query: string, scope: {
321
+ agent?: string;
322
+ project?: string;
323
+ local?: boolean;
324
+ }): Promise<void>;
318
325
  /**
319
326
  * The one-line live status banner shown above a session preview: the glyph, the
320
327
  * status word, and — when the session needs a human or has LOST one — a plain
@@ -351,6 +358,16 @@ export declare function teamBadge(session: SessionMeta): {
351
358
  * when `showTicket` — otherwise a listing with no refs would waste a column of
352
359
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
353
360
  export declare function flatSessionRow(session: SessionMeta, live?: ActiveSession, showTicket?: boolean, cols?: PickerColumns, favorite?: boolean): string;
361
+ /**
362
+ * Live-session index for enriching the default listing, or undefined when
363
+ * enrichment is off (`--no-live`) or irrelevant (`--json`, which serializes
364
+ * SessionMeta). Full detection (incl. the headless `ps` scan) is deliberate:
365
+ * bare-CLI and tmux agents are the common case here, and skipping them would
366
+ * leave the glyph almost never showing. The listing is a one-shot user action,
367
+ * not a hot loop, so the `ps`/`lsof` cost is acceptable; `--no-live` is the
368
+ * escape hatch. Never throws — a probe failure just yields a plain listing.
369
+ */
370
+ export declare function maybeLiveIndex(options: SessionsOptions): Promise<Map<string, ActiveSession> | undefined>;
354
371
  /**
355
372
  * Group key for the overview: resolve the cwd through the same canonical
356
373
  * resolver the `agents activity` timeline groups by — a defined project's name