@phnx-labs/agents-cli 1.20.35 → 1.20.36

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 (225) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +185 -21
  3. package/README.md +8 -4
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer.js +81 -0
  11. package/dist/commands/daemon.js +4 -1
  12. package/dist/commands/doctor.js +1 -89
  13. package/dist/commands/events.js +3 -3
  14. package/dist/commands/exec.d.ts +19 -0
  15. package/dist/commands/exec.js +277 -11
  16. package/dist/commands/hosts.js +10 -6
  17. package/dist/commands/inspect.js +8 -9
  18. package/dist/commands/lock.d.ts +12 -0
  19. package/dist/commands/lock.js +70 -0
  20. package/dist/commands/message.d.ts +15 -0
  21. package/dist/commands/message.js +56 -0
  22. package/dist/commands/routines.js +101 -5
  23. package/dist/commands/secrets-migrate.js +106 -57
  24. package/dist/commands/secrets.d.ts +31 -18
  25. package/dist/commands/secrets.js +156 -75
  26. package/dist/commands/serve.d.ts +10 -0
  27. package/dist/commands/serve.js +37 -0
  28. package/dist/commands/sessions-inject.d.ts +14 -0
  29. package/dist/commands/sessions-inject.js +111 -0
  30. package/dist/commands/sessions-picker.d.ts +2 -0
  31. package/dist/commands/sessions-picker.js +24 -3
  32. package/dist/commands/sessions-resume.js +20 -8
  33. package/dist/commands/sessions.d.ts +71 -1
  34. package/dist/commands/sessions.js +365 -37
  35. package/dist/commands/setup.js +4 -2
  36. package/dist/commands/sync.d.ts +3 -1
  37. package/dist/commands/sync.js +156 -4
  38. package/dist/commands/teams.js +217 -0
  39. package/dist/commands/versions.js +2 -4
  40. package/dist/commands/watchdog.d.ts +18 -0
  41. package/dist/commands/watchdog.js +238 -0
  42. package/dist/index.js +25 -2
  43. package/dist/lib/audit/log.d.ts +92 -0
  44. package/dist/lib/audit/log.js +177 -0
  45. package/dist/lib/auto-pull.js +2 -1
  46. package/dist/lib/browser/chrome.d.ts +10 -0
  47. package/dist/lib/browser/chrome.js +18 -7
  48. package/dist/lib/browser/drivers/ssh.js +2 -1
  49. package/dist/lib/browser/har.d.ts +84 -0
  50. package/dist/lib/browser/har.js +77 -0
  51. package/dist/lib/browser/ipc.js +24 -3
  52. package/dist/lib/browser/profiles.d.ts +1 -1
  53. package/dist/lib/browser/profiles.js +8 -10
  54. package/dist/lib/browser/refs.d.ts +65 -0
  55. package/dist/lib/browser/refs.js +73 -1
  56. package/dist/lib/browser/runtime-state.js +1 -0
  57. package/dist/lib/browser/service.d.ts +38 -2
  58. package/dist/lib/browser/service.js +112 -8
  59. package/dist/lib/browser/types.d.ts +14 -1
  60. package/dist/lib/budget/live-cloud.d.ts +42 -0
  61. package/dist/lib/budget/live-cloud.js +79 -0
  62. package/dist/lib/budget/live-team.d.ts +31 -0
  63. package/dist/lib/budget/live-team.js +115 -0
  64. package/dist/lib/cloud/codex.js +4 -0
  65. package/dist/lib/cloud/rush.d.ts +12 -1
  66. package/dist/lib/cloud/rush.js +13 -3
  67. package/dist/lib/cloud/types.d.ts +9 -0
  68. package/dist/lib/computer/dispatch.d.ts +8 -0
  69. package/dist/lib/computer/dispatch.js +125 -0
  70. package/dist/lib/computer/loop.d.ts +62 -0
  71. package/dist/lib/computer/loop.js +98 -0
  72. package/dist/lib/computer/model.d.ts +44 -0
  73. package/dist/lib/computer/model.js +157 -0
  74. package/dist/lib/concurrency.d.ts +19 -0
  75. package/dist/lib/concurrency.js +33 -0
  76. package/dist/lib/daemon.d.ts +57 -0
  77. package/dist/lib/daemon.js +192 -16
  78. package/dist/lib/devices/registry.d.ts +7 -0
  79. package/dist/lib/devices/registry.js +24 -0
  80. package/dist/lib/devices/tailscale.js +1 -1
  81. package/dist/lib/drift.d.ts +52 -0
  82. package/dist/lib/drift.js +112 -0
  83. package/dist/lib/events.d.ts +1 -1
  84. package/dist/lib/events.js +31 -13
  85. package/dist/lib/exec.d.ts +17 -0
  86. package/dist/lib/exec.js +79 -13
  87. package/dist/lib/git.d.ts +27 -0
  88. package/dist/lib/git.js +56 -1
  89. package/dist/lib/hooks/cache.d.ts +6 -0
  90. package/dist/lib/hooks/cache.js +54 -12
  91. package/dist/lib/hooks.d.ts +27 -0
  92. package/dist/lib/hooks.js +127 -8
  93. package/dist/lib/hosts/dispatch.d.ts +15 -0
  94. package/dist/lib/hosts/dispatch.js +39 -6
  95. package/dist/lib/hosts/logs.js +30 -1
  96. package/dist/lib/hosts/option.js +1 -1
  97. package/dist/lib/hosts/passthrough.js +3 -1
  98. package/dist/lib/hosts/ready.d.ts +29 -6
  99. package/dist/lib/hosts/ready.js +66 -15
  100. package/dist/lib/hosts/registry.d.ts +19 -2
  101. package/dist/lib/hosts/registry.js +58 -2
  102. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  103. package/dist/lib/hosts/remote-cmd.js +70 -1
  104. package/dist/lib/hosts/remote-os.d.ts +17 -0
  105. package/dist/lib/hosts/remote-os.js +30 -0
  106. package/dist/lib/hosts/session-index.d.ts +34 -0
  107. package/dist/lib/hosts/session-index.js +56 -0
  108. package/dist/lib/hosts/tasks.d.ts +14 -0
  109. package/dist/lib/hosts/tasks.js +15 -0
  110. package/dist/lib/lock.d.ts +93 -0
  111. package/dist/lib/lock.js +207 -0
  112. package/dist/lib/loop.js +16 -1
  113. package/dist/lib/machine-id.d.ts +21 -0
  114. package/dist/lib/machine-id.js +26 -0
  115. package/dist/lib/mailbox-target.d.ts +36 -0
  116. package/dist/lib/mailbox-target.js +45 -0
  117. package/dist/lib/mailbox.d.ts +47 -0
  118. package/dist/lib/mailbox.js +194 -0
  119. package/dist/lib/mcp.d.ts +5 -0
  120. package/dist/lib/mcp.js +24 -8
  121. package/dist/lib/migrate.d.ts +19 -0
  122. package/dist/lib/migrate.js +134 -26
  123. package/dist/lib/overdue.js +3 -0
  124. package/dist/lib/picker.d.ts +2 -0
  125. package/dist/lib/picker.js +4 -1
  126. package/dist/lib/platform/exec.d.ts +46 -0
  127. package/dist/lib/platform/exec.js +74 -0
  128. package/dist/lib/platform/process.d.ts +31 -0
  129. package/dist/lib/platform/process.js +34 -1
  130. package/dist/lib/platform/winpath.js +2 -0
  131. package/dist/lib/plugins.js +16 -6
  132. package/dist/lib/profiles.d.ts +25 -0
  133. package/dist/lib/profiles.js +22 -6
  134. package/dist/lib/pty-client.js +2 -1
  135. package/dist/lib/rotate.d.ts +61 -0
  136. package/dist/lib/rotate.js +52 -0
  137. package/dist/lib/routines.d.ts +40 -2
  138. package/dist/lib/routines.js +66 -8
  139. package/dist/lib/runner.d.ts +11 -2
  140. package/dist/lib/runner.js +49 -7
  141. package/dist/lib/scheduler.js +6 -1
  142. package/dist/lib/secrets/bundles.d.ts +60 -4
  143. package/dist/lib/secrets/bundles.js +131 -12
  144. package/dist/lib/secrets/filestore.d.ts +3 -0
  145. package/dist/lib/secrets/filestore.js +42 -16
  146. package/dist/lib/secrets/index.d.ts +43 -2
  147. package/dist/lib/secrets/index.js +102 -3
  148. package/dist/lib/secrets/mcp.d.ts +93 -0
  149. package/dist/lib/secrets/mcp.js +205 -0
  150. package/dist/lib/secrets/remote.js +12 -5
  151. package/dist/lib/secrets/sync.js +83 -4
  152. package/dist/lib/secrets/windows.js +14 -3
  153. package/dist/lib/serve/data.d.ts +81 -0
  154. package/dist/lib/serve/data.js +91 -0
  155. package/dist/lib/serve/page.d.ts +7 -0
  156. package/dist/lib/serve/page.js +140 -0
  157. package/dist/lib/serve/server.d.ts +46 -0
  158. package/dist/lib/serve/server.js +115 -0
  159. package/dist/lib/session/active.d.ts +54 -0
  160. package/dist/lib/session/active.js +190 -19
  161. package/dist/lib/session/discover.d.ts +37 -0
  162. package/dist/lib/session/discover.js +111 -28
  163. package/dist/lib/session/inject.d.ts +18 -0
  164. package/dist/lib/session/inject.js +21 -0
  165. package/dist/lib/session/parse.js +23 -20
  166. package/dist/lib/session/pid-registry.d.ts +1 -0
  167. package/dist/lib/session/pid-registry.js +24 -0
  168. package/dist/lib/session/provenance.d.ts +14 -2
  169. package/dist/lib/session/provenance.js +39 -8
  170. package/dist/lib/session/remote-active.js +19 -7
  171. package/dist/lib/session/remote-list.d.ts +51 -0
  172. package/dist/lib/session/remote-list.js +213 -0
  173. package/dist/lib/session/remote.d.ts +7 -1
  174. package/dist/lib/session/remote.js +16 -2
  175. package/dist/lib/session/sync/config.d.ts +1 -15
  176. package/dist/lib/session/sync/config.js +4 -20
  177. package/dist/lib/session/types.d.ts +17 -0
  178. package/dist/lib/shims.d.ts +36 -6
  179. package/dist/lib/shims.js +91 -29
  180. package/dist/lib/ssh-exec.js +2 -0
  181. package/dist/lib/ssh-tunnel.js +2 -1
  182. package/dist/lib/startup/command-registry.d.ts +6 -0
  183. package/dist/lib/startup/command-registry.js +13 -1
  184. package/dist/lib/state.d.ts +13 -0
  185. package/dist/lib/state.js +103 -9
  186. package/dist/lib/sync-umbrella.d.ts +14 -7
  187. package/dist/lib/sync-umbrella.js +17 -9
  188. package/dist/lib/teams/forEach.d.ts +110 -0
  189. package/dist/lib/teams/forEach.js +186 -0
  190. package/dist/lib/teams/index.d.ts +1 -0
  191. package/dist/lib/teams/index.js +1 -0
  192. package/dist/lib/teams/pr-watch.d.ts +226 -0
  193. package/dist/lib/teams/pr-watch.js +371 -0
  194. package/dist/lib/teams/supervisor.d.ts +14 -1
  195. package/dist/lib/teams/supervisor.js +19 -0
  196. package/dist/lib/teams/worktree.d.ts +9 -0
  197. package/dist/lib/teams/worktree.js +32 -0
  198. package/dist/lib/terminal/backends/index.d.ts +2 -1
  199. package/dist/lib/terminal/backends/index.js +3 -1
  200. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  201. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  202. package/dist/lib/terminal/index.d.ts +4 -1
  203. package/dist/lib/terminal/index.js +4 -1
  204. package/dist/lib/terminal/inject.d.ts +204 -0
  205. package/dist/lib/terminal/inject.js +247 -0
  206. package/dist/lib/terminal/resolve.d.ts +64 -0
  207. package/dist/lib/terminal/resolve.js +90 -0
  208. package/dist/lib/terminal/types.d.ts +1 -1
  209. package/dist/lib/triggers/webhook.d.ts +85 -0
  210. package/dist/lib/triggers/webhook.js +141 -0
  211. package/dist/lib/versions.d.ts +23 -0
  212. package/dist/lib/versions.js +119 -13
  213. package/dist/lib/watchdog/index.d.ts +3 -0
  214. package/dist/lib/watchdog/index.js +5 -0
  215. package/dist/lib/watchdog/read.d.ts +35 -0
  216. package/dist/lib/watchdog/read.js +149 -0
  217. package/dist/lib/watchdog/runner.d.ts +127 -0
  218. package/dist/lib/watchdog/runner.js +322 -0
  219. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  220. package/dist/lib/watchdog/watchdog.js +166 -0
  221. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  222. package/dist/lib/watchdog/watchdogTail.js +154 -0
  223. package/dist/lib/workflows.d.ts +166 -0
  224. package/dist/lib/workflows.js +193 -0
  225. package/package.json +5 -4
@@ -14,6 +14,7 @@ import { getProvider, listAllHosts, resolveHost } from '../lib/hosts/registry.js
14
14
  import { sshTargetFor } from '../lib/hosts/types.js';
15
15
  import { listSshConfigHosts, listKnownHosts, isSshConfigHost } from '../lib/hosts/ssh-config.js';
16
16
  import { probeHost, remoteAgentsVersion, bootstrapAgentsCli, localCliVersion, } from '../lib/hosts/ready.js';
17
+ import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
17
18
  import { listTasks } from '../lib/hosts/tasks.js';
18
19
  import { reconcileRunningTasks } from '../lib/hosts/reconcile.js';
19
20
  import { showHostTaskLog } from '../lib/hosts/logs.js';
@@ -29,18 +30,20 @@ function parseTarget(target) {
29
30
  * Callers that just probed the host pass the result through to avoid a second,
30
31
  * redundant `uname` round-trip; otherwise we probe here.
31
32
  */
32
- async function maybeBootstrap(target, hostName, probe = probeHost(target)) {
33
+ async function maybeBootstrap(target, hostName, probe = probeHost(target, resolveRemoteOsSync(hostName))) {
33
34
  if (!probe.reachable) {
34
35
  console.log(chalk.yellow(` Not reachable over SSH yet — skipping bootstrap. Fix key auth, then: agents hosts check ${hostName}`));
35
36
  return;
36
37
  }
37
- const remoteVer = remoteAgentsVersion(target);
38
+ // Prefer the OS the probe just observed; fall back to the registry hint.
39
+ const os = probe.os ?? resolveRemoteOsSync(hostName);
40
+ const remoteVer = remoteAgentsVersion(target, os);
38
41
  const localVer = localCliVersion();
39
42
  if (!remoteVer) {
40
43
  const ok = await confirm({ message: ` agents-cli not found on ${hostName}. Install ${localVer ? `v${localVer}` : 'latest'} now?`, default: true });
41
44
  if (ok) {
42
45
  console.log(chalk.gray(' Installing agents-cli on the host…'));
43
- const r = bootstrapAgentsCli(target, localVer);
46
+ const r = bootstrapAgentsCli(target, localVer, os);
44
47
  console.log(r.ok ? chalk.green(' Installed.') : chalk.red(` Install failed:\n${r.output}`));
45
48
  }
46
49
  return;
@@ -49,7 +52,7 @@ async function maybeBootstrap(target, hostName, probe = probeHost(target)) {
49
52
  if (localVer && remoteClean !== localVer) {
50
53
  const ok = await confirm({ message: ` ${hostName} has agents-cli ${remoteClean}, you have ${localVer}. Upgrade to match?`, default: false });
51
54
  if (ok) {
52
- const r = bootstrapAgentsCli(target, localVer);
55
+ const r = bootstrapAgentsCli(target, localVer, os);
53
56
  console.log(r.ok ? chalk.green(' Upgraded.') : chalk.red(` Upgrade failed:\n${r.output}`));
54
57
  }
55
58
  }
@@ -141,15 +144,16 @@ async function doCheck(name) {
141
144
  return;
142
145
  }
143
146
  const target = sshTargetFor(host);
147
+ const os = host.os ?? resolveRemoteOsSync(name);
144
148
  process.stdout.write(`Probing ${chalk.cyan(name)} (${target})… `);
145
- const probe = probeHost(target);
149
+ const probe = probeHost(target, os);
146
150
  if (!probe.reachable) {
147
151
  console.log(chalk.red('unreachable'));
148
152
  process.exitCode = 1;
149
153
  return;
150
154
  }
151
155
  console.log(chalk.green('reachable') + chalk.gray(probe.os ? ` · ${probe.os}` : ''));
152
- const ver = remoteAgentsVersion(target);
156
+ const ver = remoteAgentsVersion(target, probe.os ?? os);
153
157
  console.log(` agents-cli: ${ver ? chalk.green(ver) : chalk.yellow('not installed')}`);
154
158
  }
155
159
  async function doRemove(name) {
@@ -11,7 +11,7 @@
11
11
  * names render as OSC-8 hyperlinks to the marker file (SKILL.md / WORKFLOW.md /
12
12
  * AGENT.md / the file itself) so users can click straight to the source.
13
13
  */
14
- import { execSync } from 'child_process';
14
+ import { execFileSync } from 'child_process';
15
15
  import { addHostOption } from '../lib/hosts/option.js';
16
16
  import * as fs from 'fs';
17
17
  import * as os from 'os';
@@ -512,35 +512,34 @@ function renderRepoSummary(repo, options) {
512
512
  export function repoGitInfo(root) {
513
513
  const git = (args) => {
514
514
  try {
515
- return execSync(`git -C ${JSON.stringify(root)} ${args}`, { stdio: ['ignore', 'pipe', 'ignore'] })
516
- .toString().trim();
515
+ return execFileSync('git', ['-C', root, ...args], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
517
516
  }
518
517
  catch {
519
518
  return null;
520
519
  }
521
520
  };
522
- const branch = git('rev-parse --abbrev-ref HEAD');
521
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']);
523
522
  if (branch === null)
524
523
  return null;
525
- // Read status WITHOUT trimming — git()'s .trim() would strip the leading
524
+ // Read status WITHOUT trimming — trimming would strip the leading
526
525
  // space of the first porcelain line (`XY path`), corrupting the path slice.
527
526
  let statusRaw;
528
527
  try {
529
- statusRaw = execSync(`git -C ${JSON.stringify(root)} status --porcelain`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
528
+ statusRaw = execFileSync('git', ['-C', root, 'status', '--porcelain'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' });
530
529
  }
531
530
  catch {
532
531
  statusRaw = null;
533
532
  }
534
533
  const dirtyFiles = statusRaw ? statusRaw.split('\n').filter(Boolean).map(l => l.slice(3)) : [];
535
534
  let lastCommit = null;
536
- const log = git('log -1 --format=%h%x1f%s%x1f%cr');
535
+ const log = git(['log', '-1', '--format=%h%x1f%s%x1f%cr']);
537
536
  if (log) {
538
537
  const [sha, subject, relative] = log.split('\x1f');
539
538
  if (sha)
540
539
  lastCommit = { sha, subject: subject ?? '', relative: relative ?? '' };
541
540
  }
542
541
  let ahead = null, behind = null;
543
- const counts = git("rev-list --left-right --count '@{upstream}...HEAD'");
542
+ const counts = git(['rev-list', '--left-right', '--count', '@{upstream}...HEAD']);
544
543
  if (counts) {
545
544
  const [b, a] = counts.split(/\s+/).map(n => parseInt(n, 10));
546
545
  if (Number.isFinite(b) && Number.isFinite(a)) {
@@ -548,7 +547,7 @@ export function repoGitInfo(root) {
548
547
  ahead = a;
549
548
  }
550
549
  }
551
- return { branch, dirty: dirtyFiles.length, dirtyFiles, url: git('remote get-url origin'), lastCommit, ahead, behind };
550
+ return { branch, dirty: dirtyFiles.length, dirtyFiles, url: git(['remote', 'get-url', 'origin']), lastCommit, ahead, behind };
552
551
  }
553
552
  // ─── Summary mode ────────────────────────────────────────────────────────────
554
553
  async function renderSummary(agent, version, versionHome, options) {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `agents lock` — write a deterministic `agents.lock` for the resolved resource
3
+ * set at the project root; `agents lock --frozen` verifies the live resources
4
+ * match that lock EXACTLY and fails closed (non-zero exit + a clear diff) on any
5
+ * drift. The reproducible-CI slice of governance #337.
6
+ *
7
+ * This is OFF the default path: normal `agents add` / `agents sync` never touch
8
+ * the lock. Generation and verification only run when this command is invoked.
9
+ */
10
+ import { Command } from 'commander';
11
+ /** Register the `agents lock` command. */
12
+ export declare function registerLockCommand(program: Command): void;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `agents lock` — write a deterministic `agents.lock` for the resolved resource
3
+ * set at the project root; `agents lock --frozen` verifies the live resources
4
+ * match that lock EXACTLY and fails closed (non-zero exit + a clear diff) on any
5
+ * drift. The reproducible-CI slice of governance #337.
6
+ *
7
+ * This is OFF the default path: normal `agents add` / `agents sync` never touch
8
+ * the lock. Generation and verification only run when this command is invoked.
9
+ */
10
+ import chalk from 'chalk';
11
+ import { enumerateLockSources, buildLock, writeLock, readLock, verifyLock, lockDiffIsClean, resolveProjectRoot, LOCK_FILENAME, } from '../lib/lock.js';
12
+ /** Register the `agents lock` command. */
13
+ export function registerLockCommand(program) {
14
+ program
15
+ .command('lock')
16
+ .summary('Write or verify agents.lock — a SHA-256 manifest of resolved resources')
17
+ .description('Capture a deterministic SHA-256 per resolved resource file (commands, skills, hooks, rules, mcp, permissions, subagents — project > user > system > extras) into an agents.lock at the project root.\n\n' +
18
+ 'Run bare to (re)generate the lock. Pass --frozen to VERIFY the live resolved resources match an existing agents.lock exactly — it exits non-zero with an added/removed/changed diff on any mismatch, and errors if no lock exists yet. That is the reproducible-CI gate; normal installs and syncs are unaffected.')
19
+ .option('--cwd <path>', 'Working directory to resolve resources and the project root from')
20
+ .option('--frozen', 'Verify resolved resources match agents.lock exactly; fail (non-zero) on any drift', false)
21
+ .action((opts) => {
22
+ runLock(opts);
23
+ });
24
+ }
25
+ function runLock(opts) {
26
+ const cwd = opts.cwd || process.cwd();
27
+ const projectRoot = resolveProjectRoot(cwd);
28
+ const sources = enumerateLockSources(cwd);
29
+ if (opts.frozen) {
30
+ verifyFrozen(projectRoot, sources);
31
+ return;
32
+ }
33
+ const lock = buildLock(sources);
34
+ const written = writeLock(projectRoot, lock);
35
+ const count = Object.keys(lock.resources).length;
36
+ console.log(chalk.green(`Wrote ${LOCK_FILENAME}`) +
37
+ chalk.gray(` — ${count} resource file(s) → ${written}`));
38
+ }
39
+ function verifyFrozen(projectRoot, sources) {
40
+ let existing;
41
+ try {
42
+ existing = readLock(projectRoot);
43
+ }
44
+ catch (e) {
45
+ console.error(chalk.red(`agents lock --frozen: ${e.message}`));
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ if (!existing) {
50
+ console.error(chalk.red(`No ${LOCK_FILENAME} at ${projectRoot}.`));
51
+ console.error(chalk.gray('Generate one first: agents lock'));
52
+ process.exitCode = 1;
53
+ return;
54
+ }
55
+ const diff = verifyLock(existing, sources);
56
+ if (lockDiffIsClean(diff)) {
57
+ const count = Object.keys(existing.resources).length;
58
+ console.log(chalk.green(`✓ ${count} resource file(s) match ${LOCK_FILENAME}`));
59
+ return;
60
+ }
61
+ console.error(chalk.red(`✗ resources drifted from ${LOCK_FILENAME}:`));
62
+ for (const key of diff.changed)
63
+ console.error(chalk.yellow(` changed ${key}`));
64
+ for (const key of diff.added)
65
+ console.error(chalk.yellow(` added ${key}`));
66
+ for (const key of diff.removed)
67
+ console.error(chalk.yellow(` removed ${key}`));
68
+ console.error(chalk.gray('Re-lock once the change is intended: agents lock'));
69
+ process.exitCode = 1;
70
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `agents message <target> <text>` — send a message to a running agent.
3
+ *
4
+ * Unifies two delivery paths behind one verb:
5
+ * - a live LOCAL/teams/loop agent → enqueue into its file-spool mailbox; the
6
+ * PreToolUse hook injects it at the agent's next tool call.
7
+ * - a CLOUD task id → the existing provider follow-up path (was
8
+ * `agents cloud message`).
9
+ *
10
+ * Cross-host is handled one layer up: `--host <h>` routes the whole command over
11
+ * ssh via `REMOTE_PASSTHROUGH` (see src/lib/hosts/passthrough.ts), so the box is
12
+ * written on the host that actually owns the agent.
13
+ */
14
+ import type { Command } from 'commander';
15
+ export declare function registerMessageCommand(program: Command): void;
@@ -0,0 +1,56 @@
1
+ import chalk from 'chalk';
2
+ import { getActiveSessions } from '../lib/session/active.js';
3
+ import { getTaskById, updateTaskStatus } from '../lib/cloud/store.js';
4
+ import { resolveProvider } from '../lib/cloud/registry.js';
5
+ import { mailboxDir, enqueue } from '../lib/mailbox.js';
6
+ import { resolveMessageTarget } from '../lib/mailbox-target.js';
7
+ function die(msg, code = 1) {
8
+ console.error(chalk.red(msg));
9
+ process.exit(code);
10
+ }
11
+ export function registerMessageCommand(program) {
12
+ program
13
+ .command('message <target> <text>')
14
+ .description('Send a message to a running agent (delivered at its next tool call) or a cloud task.')
15
+ .option('--from <who>', 'Label recorded as the sender of this message')
16
+ .action(async (target, text, opts) => {
17
+ if (!target.trim()) {
18
+ die('Target must be a session/agent id or cloud task id. Run `agents sessions --active` to list running agents.');
19
+ }
20
+ const sessions = await getActiveSessions();
21
+ const res = resolveMessageTarget(target, sessions, (id) => getTaskById(id) != null);
22
+ switch (res.kind) {
23
+ case 'cloud': {
24
+ const task = getTaskById(res.id);
25
+ const provider = resolveProvider(task.provider);
26
+ try {
27
+ await provider.message(res.id, text);
28
+ updateTaskStatus(res.id, 'running');
29
+ console.log(chalk.green(`Message sent to cloud task ${res.id}. Agent is continuing.`));
30
+ }
31
+ catch (err) {
32
+ die(err.message);
33
+ }
34
+ return;
35
+ }
36
+ case 'local': {
37
+ try {
38
+ const msgId = enqueue(mailboxDir(res.id), { to: res.id, text, from: opts.from });
39
+ console.log(chalk.green(`Queued message ${msgId} for ${res.id}. `) +
40
+ chalk.dim('The agent will see it at its next tool call.'));
41
+ }
42
+ catch (err) {
43
+ die(err.message);
44
+ }
45
+ return;
46
+ }
47
+ case 'ambiguous': {
48
+ const lines = res.candidates.map((c) => ` ${c.id} ${chalk.dim(c.label)}`).join('\n');
49
+ die(`"${target}" matches ${res.candidates.length} running agents:\n${lines}\nRe-run with a full id.`);
50
+ return;
51
+ }
52
+ case 'none':
53
+ die(`No running agent or cloud task matches "${target}". List targets with \`agents sessions --active\`.`);
54
+ }
55
+ });
56
+ }
@@ -13,6 +13,7 @@ import * as yaml from 'yaml';
13
13
  import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonPid, readDaemonLog, } from '../lib/daemon.js';
14
14
  import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
15
15
  import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, } from '../lib/routines.js';
16
+ import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js';
16
17
  import { getRoutinesDir } from '../lib/state.js';
17
18
  import { IS_WINDOWS } from '../lib/platform/index.js';
18
19
  import { safeJoin } from '../lib/paths.js';
@@ -21,6 +22,21 @@ import { JobScheduler } from '../lib/scheduler.js';
21
22
  import { detectOverdueJobs } from '../lib/overdue.js';
22
23
  import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
23
24
  import { setHelpSections } from '../lib/help.js';
25
+ /**
26
+ * Human label for what fires a job: its cron schedule, or its event trigger
27
+ * for schedule-less (trigger-only) routines.
28
+ */
29
+ function fireConditionLabel(job) {
30
+ if (job.schedule)
31
+ return humanizeCron(job.schedule, job.timezone);
32
+ if (job.trigger) {
33
+ const scope = job.trigger.repo
34
+ ? ` (${job.trigger.repo}${job.trigger.branch ? `@${job.trigger.branch}` : ''})`
35
+ : '';
36
+ return `on ${job.trigger.event}${scope}`;
37
+ }
38
+ return '-';
39
+ }
24
40
  /** Start or reload the background scheduler so newly-added jobs fire on time. */
25
41
  function ensureSchedulerRunning() {
26
42
  if (isDaemonRunning()) {
@@ -73,7 +89,7 @@ async function pickJob(message, filter, alternatives = [], cwd) {
73
89
  message,
74
90
  choices: jobs.map((job) => ({
75
91
  value: job.name,
76
- name: `${job.name} ${chalk.gray(`(${job.workflow ? `wf:${job.workflow}` : job.agent}, ${job.schedule})`)}`,
92
+ name: `${job.name} ${chalk.gray(`(${job.workflow ? `wf:${job.workflow}` : job.agent}, ${job.schedule ?? fireConditionLabel(job)})`)}`,
77
93
  })),
78
94
  });
79
95
  }
@@ -159,8 +175,9 @@ export function registerRoutinesCommands(program) {
159
175
  agent: job.agent ?? null,
160
176
  workflow: job.workflow ?? null,
161
177
  repo: job.repo ?? null,
162
- schedule: job.schedule,
163
- scheduleHuman: humanizeCron(job.schedule, job.timezone),
178
+ schedule: job.schedule ?? null,
179
+ scheduleHuman: fireConditionLabel(job),
180
+ trigger: job.trigger ?? null,
164
181
  timezone: job.timezone ?? null,
165
182
  enabled: job.enabled,
166
183
  overdue: overdueSet.has(job.name),
@@ -193,7 +210,7 @@ export function registerRoutinesCommands(program) {
193
210
  for (const job of jobs) {
194
211
  const nextRun = scheduler.getNextRun(job.name);
195
212
  const nextStr = humanizeNextRun(nextRun ?? null, now, job.timezone);
196
- let schedStr = humanizeCron(job.schedule, job.timezone);
213
+ let schedStr = fireConditionLabel(job);
197
214
  if (job.endAt) {
198
215
  const end = new Date(job.endAt);
199
216
  const endLabel = Number.isFinite(end.getTime())
@@ -565,6 +582,82 @@ export function registerRoutinesCommands(program) {
565
582
  }
566
583
  console.log(chalk.gray('\nTrack progress with: agents routines runs <name>'));
567
584
  });
585
+ routinesCmd
586
+ .command('webhook')
587
+ .description('Fire trigger-based routines from a single GitHub webhook payload (read from --file or stdin). One-shot: matches and fires, then exits. For a long-running receiver, run this behind your own HTTP forwarder.')
588
+ .requiredOption('--event <name>', 'GitHub event name, as sent in the X-GitHub-Event header (e.g. pull_request, push, workflow_run)')
589
+ .option('--file <path>', 'Read the webhook JSON payload from this file instead of stdin')
590
+ .option('--dry-run', 'Show which routines would fire without firing them')
591
+ .action(async (options) => {
592
+ // Load the raw JSON payload: --file wins, else drain stdin.
593
+ let raw;
594
+ if (options.file) {
595
+ const resolved = path.resolve(options.file);
596
+ if (!fs.existsSync(resolved)) {
597
+ console.log(chalk.red(`File not found: ${resolved}`));
598
+ process.exit(1);
599
+ }
600
+ raw = fs.readFileSync(resolved, 'utf-8');
601
+ }
602
+ else {
603
+ if (process.stdin.isTTY) {
604
+ console.log(chalk.red('No payload provided. Pass --file <path> or pipe the webhook JSON on stdin.'));
605
+ process.exit(1);
606
+ }
607
+ const chunks = [];
608
+ for await (const chunk of process.stdin)
609
+ chunks.push(chunk);
610
+ raw = Buffer.concat(chunks).toString('utf-8');
611
+ }
612
+ let payload;
613
+ try {
614
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
615
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
616
+ throw new Error('payload must be a JSON object');
617
+ }
618
+ payload = parsed;
619
+ }
620
+ catch (err) {
621
+ console.log(chalk.red(`Invalid webhook payload JSON: ${err.message}`));
622
+ process.exit(1);
623
+ }
624
+ const webhook = { event: options.event, payload };
625
+ // Matching is intentionally user-layer only (fireWebhookJobs defaults to
626
+ // listJobs() with no cwd), mirroring `run`/`catchup`: a webhook must never
627
+ // fire a cloned project repo's `.agents/routines/*.yml` and run an
628
+ // attacker-supplied prompt under the user's agent session.
629
+ if (options.dryRun) {
630
+ const matched = matchJobsToWebhook(listAllJobs(), webhook);
631
+ if (matched.length === 0) {
632
+ console.log(chalk.gray(`No routines match a ${options.event} event for this payload.`));
633
+ return;
634
+ }
635
+ console.log(chalk.bold(`${matched.length} routine(s) would fire on ${options.event}:\n`));
636
+ for (const job of matched) {
637
+ console.log(` ${chalk.cyan(job.name)} — ${fireConditionLabel(job)}`);
638
+ }
639
+ console.log(chalk.gray('\n(dry run — no routines triggered)'));
640
+ return;
641
+ }
642
+ // Fired jobs run detached via executeJobDetached (the same path cron
643
+ // uses). Keep the daemon alive so each run's meta.json is finalized.
644
+ if (!isDaemonRunning()) {
645
+ const started = startDaemon();
646
+ if (started.pid) {
647
+ console.log(chalk.gray(`Started scheduler (PID: ${started.pid}) so webhook runs are monitored.`));
648
+ }
649
+ }
650
+ const fired = await fireWebhookJobs(webhook);
651
+ if (fired.length === 0) {
652
+ console.log(chalk.gray(`No routines match a ${options.event} event for this payload.`));
653
+ return;
654
+ }
655
+ console.log(chalk.bold(`Fired ${fired.length} routine(s) on ${options.event}:\n`));
656
+ for (const f of fired) {
657
+ console.log(` ${chalk.cyan(f.jobName)} → ${chalk.green('started')} (run: ${f.runId})`);
658
+ }
659
+ console.log(chalk.gray('\nTrack progress with: agents routines runs <name>'));
660
+ });
568
661
  routinesCmd
569
662
  .command('logs [name]')
570
663
  .description('Read stdout from the most recent execution. Use --run to see a specific past run.')
@@ -675,9 +768,12 @@ export function registerRoutinesCommands(program) {
675
768
  if (result.method === 'already-running') {
676
769
  console.log(chalk.yellow(`Scheduler already running (PID: ${result.pid})`));
677
770
  }
678
- else {
771
+ else if (result.pid) {
679
772
  console.log(chalk.green(`Scheduler started (PID: ${result.pid})`));
680
773
  }
774
+ else {
775
+ console.log(chalk.yellow('Scheduler start dispatched but no PID surfaced. Check `agents routines status`.'));
776
+ }
681
777
  });
682
778
  routinesCmd
683
779
  .command('stop')
@@ -23,7 +23,7 @@ import chalk from 'chalk';
23
23
  import * as crypto from 'crypto';
24
24
  import * as fs from 'fs';
25
25
  import * as path from 'path';
26
- import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, listLegacyKeychainItems, setKeychainToken, } from '../lib/secrets/index.js';
26
+ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, listLegacyKeychainItems, listOrphanedKeychainItems, migrateOrphanedKeychainItems, setKeychainToken, } from '../lib/secrets/index.js';
27
27
  import { getBackupsDir } from '../lib/state.js';
28
28
  import { encryptBlob, MIN_PASSPHRASE_LEN } from '../lib/secrets/sync.js';
29
29
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -101,7 +101,7 @@ function migrateOne(record) {
101
101
  export function registerSecretsMigrateAclCommand(secrets) {
102
102
  secrets
103
103
  .command('migrate-acl')
104
- .description('Refresh existing keychain ACLs to use the signed Agents CLI helper. Dry-run by default.')
104
+ .description('Refresh legacy keychain ACLs and re-home items stranded in a stale access group. Dry-run by default.')
105
105
  .option('--commit', 'Perform writes (default is dry-run reporting only)')
106
106
  .option('--prefix <p>', `Restrict to items beginning with PREFIX (default ${ITEM_PREFIX})`, ITEM_PREFIX)
107
107
  .option('--all', 'Rewrite EVERY matching item, not just legacy stragglers (slower; a Touch ID prompt per item)')
@@ -115,88 +115,137 @@ export function registerSecretsMigrateAclCommand(secrets) {
115
115
  if (!prefix.startsWith(ITEM_PREFIX)) {
116
116
  throw new Error(`--prefix must start with '${ITEM_PREFIX}' to avoid touching unrelated Keychain items (got '${prefix}').`);
117
117
  }
118
- // Default: migrate ONLY legacy stragglers (items still in the file-based
119
- // keychain) modern DP items need no rewrite and touching them is a
120
- // Touch ID prompt per item for nothing. `--all` forces the old full
121
- // rewrite. Either way this is one command over every matching item — no
122
- // one-by-one invocation.
118
+ // Two independent classes of work under one command:
119
+ // (a) Legacy ACL stragglers items still in the file-based keychain
120
+ // with a pre-migration trusted-app ACL. Rewritten value-by-value.
121
+ // (b) Orphaned access groups data-protection items filed under a
122
+ // pre-#279 non-concrete group, invisible to the pinned queries.
123
+ // Re-homed by the helper behind a single Touch ID.
124
+ // Default: only legacy stragglers for (a); `--all` forces a full rewrite.
123
125
  const names = opts.all ? listKeychainItems(prefix) : listLegacyKeychainItems(prefix);
124
126
  const items = names.map((item) => {
125
127
  const localExists = hasKeychainToken(item);
126
128
  return { item, sync: !localExists };
127
129
  });
128
- if (items.length === 0) {
130
+ const orphans = listOrphanedKeychainItems(prefix);
131
+ if (items.length === 0 && orphans.length === 0) {
129
132
  console.log(opts.all
130
133
  ? chalk.gray(`No keychain items with prefix '${prefix}'.`)
131
- : chalk.green(`Nothing to migrate — all items under '${prefix}' already use the modern ACL.`));
134
+ : chalk.green(`Nothing to migrate — all items under '${prefix}' use the modern ACL and access group.`));
132
135
  return;
133
136
  }
134
137
  const label = opts.all ? 'item' : 'legacy item';
135
- console.log(chalk.bold(`Found ${items.length} ${label}(s) under '${prefix}' to migrate.`));
138
+ if (items.length > 0) {
139
+ console.log(chalk.bold(`Found ${items.length} ${label}(s) under '${prefix}' with an outdated ACL.`));
140
+ }
141
+ if (orphans.length > 0) {
142
+ console.log(chalk.bold(`Found ${orphans.length} orphaned item(s) under '${prefix}' in a stale access group.`));
143
+ }
136
144
  if (!opts.commit) {
137
145
  for (const { item, sync } of items) {
138
146
  console.log(` ${chalk.cyan(item)} ${chalk.gray(sync ? '(synced)' : '(local)')}`);
139
147
  }
148
+ for (const item of orphans) {
149
+ console.log(` ${chalk.cyan(item)} ${chalk.yellow('(orphaned access group)')}`);
150
+ }
140
151
  console.log();
141
152
  console.log(chalk.gray('Dry-run — pass --commit to perform the migration.'));
142
153
  return;
143
154
  }
144
- // Commit phase. Snapshot every value first (one batched read behind a
145
- // single helper process, so DP items share one auth context), encrypt,
146
- // then mutate.
147
- const fetched = getKeychainTokens(items.map((i) => i.item));
148
- const records = [];
149
- for (const { item, sync } of items) {
150
- const value = fetched.get(item);
151
- if (value === undefined) {
152
- console.error(chalk.red(`Skipping '${item}': read failed or item absent.`));
153
- continue;
155
+ // ---- Commit phase ----
156
+ let okCount = 0;
157
+ let failCount = 0;
158
+ // (a) Legacy ACL rewrite. Snapshot every value first (one batched read
159
+ // behind a single helper process), encrypt, then delete + re-add. A total
160
+ // read failure here counts the legacy items as failed but does NOT abort —
161
+ // the orphan sweep (b) is independent and must still run.
162
+ if (items.length > 0) {
163
+ const fetched = getKeychainTokens(items.map((i) => i.item));
164
+ const records = [];
165
+ for (const { item, sync } of items) {
166
+ const value = fetched.get(item);
167
+ if (value === undefined) {
168
+ console.error(chalk.red(`Skipping '${item}': read failed or item absent.`));
169
+ continue;
170
+ }
171
+ records.push({ item, sync, value });
154
172
  }
155
- records.push({ item, sync, value });
156
- }
157
- if (records.length === 0) {
158
- console.error(chalk.red('No items could be read. Aborting before any writes.'));
159
- process.exit(1);
160
- }
161
- const passphrase = opts.passphraseEnv
162
- ? (() => {
163
- const v = process.env[opts.passphraseEnv];
164
- if (!v)
165
- throw new Error(`Env var '${opts.passphraseEnv}' not set.`);
166
- if (v.length < MIN_PASSPHRASE_LEN)
167
- throw new Error(`Passphrase must be at least ${MIN_PASSPHRASE_LEN} characters.`);
168
- return v;
169
- })()
170
- : await promptPassphrase();
171
- const backupPath = writeEncryptedBackup(records, passphrase);
172
- // Compute a quick fingerprint so the user can sanity-check recovery without
173
- // decrypting. Hash of (count, sorted item names).
174
- const fingerprint = crypto
175
- .createHash('sha256')
176
- .update(records.length + '\n' + records.map((r) => r.item).sort().join('\n'))
177
- .digest('hex')
178
- .slice(0, 12);
179
- console.log(chalk.green(`Encrypted backup written to ${backupPath} (sha256-12: ${fingerprint}).`));
180
- const results = [];
181
- for (const record of records) {
182
- const r = migrateOne(record);
183
- results.push(r);
184
- if (r.status === 'ok') {
185
- console.log(` ${chalk.green('ok')} ${record.item}`);
173
+ if (records.length === 0) {
174
+ console.error(chalk.red('No legacy items could be read — skipping legacy rewrite.'));
175
+ failCount += items.length;
186
176
  }
187
177
  else {
188
- console.log(` ${chalk.red(r.status)} ${record.item} ${chalk.gray(r.detail ?? '')}`);
178
+ const passphrase = opts.passphraseEnv
179
+ ? (() => {
180
+ const v = process.env[opts.passphraseEnv];
181
+ if (!v)
182
+ throw new Error(`Env var '${opts.passphraseEnv}' not set.`);
183
+ if (v.length < MIN_PASSPHRASE_LEN)
184
+ throw new Error(`Passphrase must be at least ${MIN_PASSPHRASE_LEN} characters.`);
185
+ return v;
186
+ })()
187
+ : await promptPassphrase();
188
+ const backupPath = writeEncryptedBackup(records, passphrase);
189
+ // Quick fingerprint so the user can sanity-check recovery without decrypting.
190
+ const fingerprint = crypto
191
+ .createHash('sha256')
192
+ .update(records.length + '\n' + records.map((r) => r.item).sort().join('\n'))
193
+ .digest('hex')
194
+ .slice(0, 12);
195
+ console.log(chalk.green(`Encrypted backup written to ${backupPath} (sha256-12: ${fingerprint}).`));
196
+ const results = [];
197
+ for (const record of records) {
198
+ const r = migrateOne(record);
199
+ results.push(r);
200
+ if (r.status === 'ok') {
201
+ console.log(` ${chalk.green('ok')} ${record.item}`);
202
+ }
203
+ else {
204
+ console.log(` ${chalk.red(r.status)} ${record.item} ${chalk.gray(r.detail ?? '')}`);
205
+ }
206
+ }
207
+ const ok = results.filter((r) => r.status === 'ok').length;
208
+ okCount += ok;
209
+ failCount += results.length - ok;
210
+ if (results.length - ok > 0) {
211
+ console.error(chalk.gray(`Restore from ${backupPath} using the backup passphrase if needed.`));
212
+ }
213
+ }
214
+ }
215
+ // (b) Orphan re-home — one helper call, one Touch ID for the batch. The
216
+ // helper adds the pinned copy before deleting the orphan, so no pre-write
217
+ // backup is needed (a failed add leaves the orphan intact and readable).
218
+ if (orphans.length > 0) {
219
+ console.log(chalk.bold(`Re-homing ${orphans.length} orphaned item(s) into the current access group (one Touch ID)…`));
220
+ const results = migrateOrphanedKeychainItems(prefix);
221
+ const healed = new Set();
222
+ for (const r of results) {
223
+ healed.add(r.item);
224
+ if (r.status === 'ok') {
225
+ okCount += 1;
226
+ console.log(` ${chalk.green('ok')} ${r.item}`);
227
+ }
228
+ else {
229
+ failCount += 1;
230
+ console.log(` ${chalk.red(r.status)} ${r.item} ${chalk.gray(r.detail ?? '')}`);
231
+ }
232
+ }
233
+ // Any orphan we listed but the helper couldn't reach (e.g. under a
234
+ // different signing team) — surface it, never drop it silently.
235
+ for (const item of orphans) {
236
+ if (!healed.has(item)) {
237
+ failCount += 1;
238
+ console.log(` ${chalk.red('fail')} ${item} ${chalk.gray('unreachable (different signing team?)')}`);
239
+ }
189
240
  }
190
241
  }
191
- const okCount = results.filter((r) => r.status === 'ok').length;
192
- const failCount = results.length - okCount;
242
+ const total = okCount + failCount;
193
243
  console.log();
194
244
  if (failCount === 0) {
195
- console.log(chalk.green(`Migrated ${okCount}/${results.length} item(s).`));
245
+ console.log(chalk.green(`Migrated ${okCount}/${total} item(s).`));
196
246
  }
197
247
  else {
198
- console.error(chalk.yellow(`Migrated ${okCount}/${results.length} item(s); ${failCount} failed.`));
199
- console.error(chalk.gray(`Restore from ${backupPath} using the backup passphrase if needed.`));
248
+ console.error(chalk.yellow(`Migrated ${okCount}/${total} item(s); ${failCount} failed.`));
200
249
  process.exit(1);
201
250
  }
202
251
  }