@phnx-labs/agents-cli 1.22.33 → 1.22.35

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 (81) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +9 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.js +3 -3
  5. package/dist/commands/browser-sessions-picker.d.ts +16 -0
  6. package/dist/commands/browser-sessions-picker.js +179 -0
  7. package/dist/commands/browser.js +9 -4
  8. package/dist/commands/hosts.js +1 -5
  9. package/dist/commands/inspect.js +174 -41
  10. package/dist/commands/message.d.ts +6 -1
  11. package/dist/commands/message.js +60 -3
  12. package/dist/commands/sessions.d.ts +54 -4
  13. package/dist/commands/sessions.js +252 -46
  14. package/dist/commands/share.d.ts +18 -0
  15. package/dist/commands/share.js +108 -0
  16. package/dist/commands/ssh.js +17 -5
  17. package/dist/commands/teams.d.ts +28 -0
  18. package/dist/commands/teams.js +148 -13
  19. package/dist/commands/upgrade.d.ts +7 -0
  20. package/dist/commands/upgrade.js +10 -0
  21. package/dist/commands/watchdog.d.ts +2 -0
  22. package/dist/commands/watchdog.js +112 -27
  23. package/dist/index.js +51 -59
  24. package/dist/lib/agents.js +6 -0
  25. package/dist/lib/browser/sessions-list.d.ts +81 -0
  26. package/dist/lib/browser/sessions-list.js +179 -4
  27. package/dist/lib/codex-policy.d.ts +9 -1
  28. package/dist/lib/codex-policy.js +17 -2
  29. package/dist/lib/daemon.js +45 -5
  30. package/dist/lib/devices/connect.d.ts +33 -0
  31. package/dist/lib/devices/connect.js +61 -3
  32. package/dist/lib/devices/doctor-findings.d.ts +4 -2
  33. package/dist/lib/devices/doctor-findings.js +4 -2
  34. package/dist/lib/exec.js +65 -8
  35. package/dist/lib/help.d.ts +3 -2
  36. package/dist/lib/help.js +4 -0
  37. package/dist/lib/hosts/dispatch.d.ts +2 -32
  38. package/dist/lib/hosts/dispatch.js +6 -61
  39. package/dist/lib/hosts/tasks.d.ts +7 -0
  40. package/dist/lib/hosts/tasks.js +9 -0
  41. package/dist/lib/mailbox-target.d.ts +27 -0
  42. package/dist/lib/mailbox-target.js +21 -0
  43. package/dist/lib/mcp.d.ts +10 -0
  44. package/dist/lib/mcp.js +21 -2
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  47. package/dist/lib/migrate.d.ts +11 -0
  48. package/dist/lib/migrate.js +40 -0
  49. package/dist/lib/project-key.d.ts +17 -0
  50. package/dist/lib/project-key.js +26 -0
  51. package/dist/lib/project-root.d.ts +47 -0
  52. package/dist/lib/project-root.js +68 -0
  53. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  54. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  55. package/dist/lib/secrets/agent.d.ts +9 -2
  56. package/dist/lib/secrets/agent.js +52 -7
  57. package/dist/lib/secrets/reaper.d.ts +24 -3
  58. package/dist/lib/secrets/reaper.js +55 -6
  59. package/dist/lib/session/discover.js +12 -0
  60. package/dist/lib/session/render.d.ts +2 -0
  61. package/dist/lib/session/render.js +1 -1
  62. package/dist/lib/share/delete.d.ts +93 -0
  63. package/dist/lib/share/delete.js +127 -0
  64. package/dist/lib/shims.js +48 -4
  65. package/dist/lib/startup/command-registry.d.ts +1 -0
  66. package/dist/lib/startup/command-registry.js +11 -3
  67. package/dist/lib/startup/root-command.d.ts +3 -0
  68. package/dist/lib/startup/root-command.js +10 -0
  69. package/dist/lib/teams/agents.d.ts +136 -6
  70. package/dist/lib/teams/agents.js +324 -58
  71. package/dist/lib/teams/worktree.d.ts +39 -2
  72. package/dist/lib/teams/worktree.js +60 -4
  73. package/dist/lib/types.d.ts +11 -0
  74. package/dist/lib/versions.js +2 -2
  75. package/dist/lib/watchdog/history.d.ts +20 -0
  76. package/dist/lib/watchdog/history.js +46 -0
  77. package/dist/lib/watchdog/log.d.ts +16 -1
  78. package/dist/lib/watchdog/log.js +82 -2
  79. package/dist/lib/watchdog/runner.d.ts +12 -0
  80. package/dist/lib/watchdog/runner.js +20 -0
  81. package/package.json +1 -1
@@ -27,11 +27,11 @@ import { resolveSingleAgentTarget, AgentSpecError } from '../lib/agent-spec/inde
27
27
  import { readMeta, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../lib/state.js';
28
28
  import { getVersionHomePath, isVersionIsolated, getIsolatedDefault, } from '../lib/versions.js';
29
29
  import { getShimsDir, getVersionedAliasPath } from '../lib/shims.js';
30
- import { getAgentResources, listResources, } from '../lib/resources.js';
30
+ import { getAgentResources, isDirectoryDoc, listResources, } from '../lib/resources.js';
31
31
  import { listHookEntriesFromDir } from '../lib/hooks.js';
32
32
  import { getResourceInventory } from '../lib/resource-inventory.js';
33
33
  import { listMcpServerConfigs, discoverMcpConfigsFromRepo } from '../lib/mcp.js';
34
- import { discoverPlugins, discoverPluginsInDir, pluginResourceGroups } from '../lib/plugins.js';
34
+ import { discoverPlugins, discoverPluginsInDir, pluginResourceGroups, inspectPluginCapabilities, pluginCapabilityLabels } from '../lib/plugins.js';
35
35
  import { PLUGIN_GROUP_COLORS } from './plugins.js';
36
36
  import { countSessionsInScope } from '../lib/session/discover.js';
37
37
  import { isSessionTrackedAgent } from '../lib/session/types.js';
@@ -292,7 +292,36 @@ export function collectRepoKind(repo, kind) {
292
292
  .map(p => pluginToItem(p, repo.label))
293
293
  .sort((a, b) => a.name.localeCompare(b.name));
294
294
  }
295
- const dir = path.join(repo.root, kind);
295
+ // Hooks live nested under event directories (`hooks/pre-tool-use/…`), and a
296
+ // script pairs with its data sidecar. repoHookItems already reads them that
297
+ // way for the summary view; a flat readdir here returned the event dirs
298
+ // themselves plus README/test scaffolding, so `--hooks` and the summary
299
+ // reported different counts for the same repo.
300
+ if (kind === 'hooks')
301
+ return repoHookItems(repo);
302
+ // A repo's `rules/` holds the COMPOSED output (AGENTS.md plus its CLAUDE.md /
303
+ // GEMINI.md symlinks) alongside a `subrules/` dir of the individually named
304
+ // fragments. The fragments are what `--rule <name>` resolves and what a reader
305
+ // means by "a rule"; the composed file is a build artifact. Without this,
306
+ // `subrules` listed as a single opaque leaf and every real rule was unreachable.
307
+ // Deliberately exclusive: once `subrules/` exists it is the sole home for
308
+ // rules (composeRules only ever resolves names under it — lib/rules/compose.ts),
309
+ // so loose top-level `.md` files are not listed. A repo mid-migration to the
310
+ // subrules convention would see those legacy files disappear from `--rules`;
311
+ // that is the correct signal, since composeRules would not load them either.
312
+ if (kind === 'rules') {
313
+ const subrulesDir = path.join(repo.root, kind, 'subrules');
314
+ if (fs.existsSync(subrulesDir))
315
+ return readResourceDir(subrulesDir, kind, repo.label);
316
+ }
317
+ return readResourceDir(path.join(repo.root, kind), kind, repo.label);
318
+ }
319
+ /**
320
+ * Enumerate one directory of resources of `kind`, skipping dotfiles, build caches,
321
+ * and directory docs. Shared so the rules `subrules/` branch and the default
322
+ * `<repo>/<kind>/` branch cannot drift apart.
323
+ */
324
+ function readResourceDir(dir, kind, source) {
296
325
  let entries;
297
326
  try {
298
327
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -307,10 +336,16 @@ export function collectRepoKind(repo, kind) {
307
336
  // Build/tooling caches are never resources — they only inflate counts.
308
337
  if (entry.name === '__pycache__' || entry.name === 'node_modules')
309
338
  continue;
339
+ const name = entry.name.replace(/\.(md|yaml|yml|toml|json)$/, '');
340
+ // README/AGENTS/CLAUDE/GEMINI describe the directory, not resources of this
341
+ // kind. CLAUDE.md/GEMINI.md are symlinks to AGENTS.md, so a Dirent reports
342
+ // isFile() === false — use !isDirectory() to catch them (mirrors resources.ts).
343
+ if (!entry.isDirectory() && isDirectoryDoc(kind, name))
344
+ continue;
310
345
  const p = path.join(dir, entry.name);
311
346
  items.push({
312
- name: entry.name.replace(/\.(md|yaml|yml|toml|json)$/, ''),
313
- source: repo.label,
347
+ name,
348
+ source,
314
349
  path: p,
315
350
  linkTarget: linkTarget(p),
316
351
  description: readDescription(p),
@@ -406,7 +441,6 @@ function renderRepoSummary(repo, options) {
406
441
  const kindData = {};
407
442
  let totalBytes = 0, totalFiles = 0;
408
443
  let repoHookByScript = new Map();
409
- let repoHookItemList = [];
410
444
  let repoMcpConfigs = new Map();
411
445
  if (!options.brief) {
412
446
  for (const kind of DRILLABLE_KINDS) {
@@ -417,7 +451,6 @@ function renderRepoSummary(repo, options) {
417
451
  totalFiles += size.files;
418
452
  }
419
453
  repoHookByScript = hookManifestByScript(hookManifestFromFile(path.join(repo.root, 'agents.yaml')));
420
- repoHookItemList = repoHookItems(repo);
421
454
  repoMcpConfigs = new Map(discoverMcpConfigsFromRepo(repo.root).map(s => [s.name, s.config]));
422
455
  }
423
456
  if (options.json) {
@@ -430,8 +463,7 @@ function renderRepoSummary(repo, options) {
430
463
  size: options.brief ? null : { bytes: totalBytes, files: totalFiles },
431
464
  resources: options.brief ? null : Object.fromEntries(DRILLABLE_KINDS.map(kind => {
432
465
  const size = kindData[kind].size;
433
- // Hooks use the grouped reader (clean names) instead of the raw readdir.
434
- const items = kind === 'hooks' ? repoHookItemList : kindData[kind].items;
466
+ const items = kindData[kind].items;
435
467
  const base = {
436
468
  count: items.length,
437
469
  bytes: size.bytes,
@@ -506,7 +538,7 @@ function renderRepoSummary(repo, options) {
506
538
  const preview = items.length > 0 ? chalk.gray(truncate(previewNames(items, 4), 60)) : '';
507
539
  console.log(` ${kind.padEnd(10)} ${count} ${sz} ${preview}`.trimEnd());
508
540
  }
509
- printExpandedSection('Hooks', hookRows(repoHookItemList, repoHookByScript));
541
+ printExpandedSection('Hooks', hookRows(kindData.hooks.items, repoHookByScript));
510
542
  printExpandedSection('Plugins', pluginRows(kindData.plugins.items));
511
543
  printExpandedSection('MCP', mcpRows(kindData.mcp.items, repoMcpConfigs));
512
544
  }
@@ -745,10 +777,23 @@ function renderItemDetail(header, jsonHead, kind, query, items, options) {
745
777
  const matchTag = best.matchKind === 'exact' ? 'exact' : best.matchKind === 'substring' ? 'substring' : `~${best.distance}`;
746
778
  console.log(` ${chalk.green('✓')} ${termLink(chalk.bold.cyan(best.item.name), best.item.linkTarget)} ${chalk.gray(`[${matchTag}, ${best.item.source}]`)}`);
747
779
  if (best.item.description) {
748
- console.log(` ${chalk.gray(truncate(best.item.description, 100))}`);
780
+ // Wrap to the real terminal width. This used to be truncate(desc, 100): a
781
+ // character count blind to the window, so a long description lost its
782
+ // sentence at 80 columns AND wasted the space at 200.
783
+ for (const line of wrapJoined(' ', best.item.description.split(/\s+/), ' ', terminalWidth())) {
784
+ console.log(chalk.gray(line));
785
+ }
749
786
  }
750
787
  for (const [k, v] of buildDetailRows(best.item, kind)) {
751
- console.log(` ${chalk.gray(k.padEnd(10))} ${v}`);
788
+ // Wrap, never truncate. These values are `, `-joined lists (commands,
789
+ // skills, triggers, tools) and cutting them hides real entries — a 9-command
790
+ // plugin would show 4. Before the detail view wrapped at all, the terminal
791
+ // soft-wrapped these in full, so truncating here would lose information the
792
+ // old output had.
793
+ // String(v) is belt-and-braces: pluginToItem now coerces every manifest field
794
+ // through manifestText, so nothing non-string should reach here. Kept because
795
+ // this is the choke point every future row kind flows through.
796
+ printWrappedJoined(` ${chalk.gray(k.padEnd(10))} `, String(v).split(', '), ', ');
752
797
  }
753
798
  if (others.length > 0) {
754
799
  console.log('\n' + chalk.gray('Other matches:'));
@@ -863,21 +908,75 @@ function pluginItems() {
863
908
  /**
864
909
  * Map a discovered plugin to a resource item, surfacing the manifest description
865
910
  * and the bundle's nested resources (skills, commands, hooks, ...) as detail rows.
911
+ *
912
+ * EVERY field read here comes from an uncontrolled `plugin.json`:
913
+ * `loadPluginManifest` casts parsed JSON straight to `PluginManifest` and
914
+ * validates only name/version (`lib/plugins.ts`), so the declared types are a
915
+ * hope, not a guarantee. A non-string reaching a renderer throws on `.split` /
916
+ * `.replace`, and `pluginToItem` runs while BUILDING THE LIST — so one malformed
917
+ * manifest anywhere takes down `inspect .`, `--plugins`, `--json`, and even a
918
+ * query for a different, valid plugin. Coerce every field through `manifestText`;
919
+ * never trust the annotation.
866
920
  */
867
921
  function pluginToItem(plugin, source) {
868
922
  const extra = [];
869
- if (plugin.manifest.version)
870
- extra.push(['version', plugin.manifest.version]);
923
+ const version = manifestText(plugin.manifest.version);
924
+ if (version)
925
+ extra.push(['version', version]);
926
+ // Which execution surfaces the bundle actually carries. Detection already
927
+ // exists for the plugin picker; the detail view simply never asked for it.
928
+ const surfaces = pluginCapabilityLabels(inspectPluginCapabilities(plugin.root));
929
+ if (surfaces.length > 0)
930
+ extra.push(['surfaces', surfaces.join(', ')]);
931
+ const author = plugin.manifest.author;
932
+ const authorLabel = manifestText(typeof author === 'object' && author !== null ? author.name : author);
933
+ if (authorLabel)
934
+ extra.push(['author', authorLabel]);
935
+ // `.length` is truthy for a bare string too, and a string has no `.join`.
936
+ const deps = plugin.manifest.dependencies;
937
+ const depsLabel = Array.isArray(deps)
938
+ ? deps.map(manifestText).filter(Boolean).join(', ')
939
+ : manifestText(deps);
940
+ if (depsLabel)
941
+ extra.push(['depends on', depsLabel]);
871
942
  return {
872
943
  name: plugin.name,
873
944
  source,
874
945
  path: plugin.root,
875
946
  linkTarget: linkTarget(plugin.root),
876
- description: plugin.manifest.description ?? '',
947
+ // `?? ''` catches only null/undefined — a numeric or array description used
948
+ // to reach truncateToWidth/`.split` and kill the render for every plugin.
949
+ description: manifestText(plugin.manifest.description),
877
950
  extra,
878
951
  groups: pluginResourceGroups(plugin),
879
952
  };
880
953
  }
954
+ /**
955
+ * Render one uncontrolled manifest value as display text. Objects and arrays
956
+ * carry no sensible one-line form, so they become '' (the row is then dropped)
957
+ * rather than `[object Object]`; everything else stringifies.
958
+ */
959
+ function manifestText(v) {
960
+ if (v === null || v === undefined)
961
+ return '';
962
+ if (typeof v === 'object')
963
+ return '';
964
+ return String(v);
965
+ }
966
+ /**
967
+ * Render one uncontrolled value as a list of display strings. A scalar becomes a
968
+ * one-element list — the case `?? []` and `Array.isArray` both miss, and the one
969
+ * that threw on `.join`. Object entries drop out rather than becoming
970
+ * `[object Object]`.
971
+ */
972
+ function manifestList(v) {
973
+ if (v === null || v === undefined)
974
+ return [];
975
+ if (Array.isArray(v))
976
+ return v.map(manifestText).filter(Boolean);
977
+ const single = manifestText(v);
978
+ return single ? [single] : [];
979
+ }
881
980
  function entriesFromAgentResources(agent, versionHome, kind) {
882
981
  const res = getAgentResources(agent, { home: versionHome });
883
982
  const list = res[kind];
@@ -926,8 +1025,17 @@ function buildDetailRows(item, kind) {
926
1025
  const rows = [];
927
1026
  if (item.path && kind !== 'mcp') {
928
1027
  const stat = safeStat(item.path);
929
- if (stat)
930
- rows.push(['size', stat.isDirectory() ? '(bundle)' : `${stat.size} bytes`]);
1028
+ // A bundle reports its real recursive weight. `(bundle)` carried no
1029
+ // information, and pathSize/formatBytes already back the summary view.
1030
+ if (stat) {
1031
+ if (stat.isDirectory()) {
1032
+ const { bytes, files } = pathSize(item.path);
1033
+ rows.push(['size', `${formatBytes(bytes)} · ${files} files`]);
1034
+ }
1035
+ else {
1036
+ rows.push(['size', formatBytes(stat.size)]);
1037
+ }
1038
+ }
931
1039
  }
932
1040
  // Kind-specific fields
933
1041
  if (kind === 'skills' || kind === 'commands' || kind === 'subagents') {
@@ -937,12 +1045,18 @@ function buildDetailRows(item, kind) {
937
1045
  if (typeof fm.description === 'string' && fm.description.trim() !== item.description.trim()) {
938
1046
  rows.push(['description', truncate(fm.description, 120)]);
939
1047
  }
940
- if (Array.isArray(fm.triggers))
941
- rows.push(['triggers', fm.triggers.join(', ')]);
942
- if (typeof fm.model === 'string')
943
- rows.push(['model', fm.model]);
944
- if (Array.isArray(fm.tools))
945
- rows.push(['tools', fm.tools.join(', ')]);
1048
+ // Frontmatter is uncontrolled YAML too. These were type-guarded against a
1049
+ // crash but still rendered `[object Object]` for an entry that is a map,
1050
+ // and dropped a scalar `triggers: foo` entirely.
1051
+ const triggers = manifestList(fm.triggers).join(', ');
1052
+ if (triggers)
1053
+ rows.push(['triggers', triggers]);
1054
+ const model = manifestText(fm.model);
1055
+ if (model)
1056
+ rows.push(['model', model]);
1057
+ const tools = manifestList(fm.tools).join(', ');
1058
+ if (tools)
1059
+ rows.push(['tools', tools]);
946
1060
  }
947
1061
  }
948
1062
  // Plugin bundles surface their nested resources (skills, commands, …) plus
@@ -966,11 +1080,14 @@ function abbrevSource(s) {
966
1080
  * an optional cache tail. Plain text — the caller applies color.
967
1081
  */
968
1082
  export function summarizeHook(hook) {
969
- const events = (hook.events ?? []).join('/') || '(no event)';
970
- let matcher = hook.matcher;
1083
+ // `hook` is an unvalidated YAML cast from agents.yaml, so `??` is not enough:
1084
+ // a scalar `events: PreToolUse` is neither null nor an array, and `.join` threw
1085
+ // — taking down bare `agents inspect <repo>`, and via the central manifest
1086
+ // `agents inspect <agent>` on every box. Same shape as the plugin.json bug.
1087
+ const events = manifestList(hook.events).join('/') || '(no event)';
1088
+ let matcher = manifestText(hook.matcher);
971
1089
  if (!matcher && hook.matches?.tool_name) {
972
- const tn = hook.matches.tool_name;
973
- matcher = Array.isArray(tn) ? tn.join('|') : tn;
1090
+ matcher = manifestList(hook.matches.tool_name).join('|');
974
1091
  }
975
1092
  const head = matcher ? `${events}(${matcher})` : events;
976
1093
  const parts = [head];
@@ -987,21 +1104,26 @@ export function summarizeHook(hook) {
987
1104
  function summarizeMatches(m) {
988
1105
  if (!m)
989
1106
  return '';
1107
+ // Every predicate is raw YAML. `truncate` calls `.slice`, so a numeric
1108
+ // `prompt_contains: 12345` threw here just like the events case above.
990
1109
  const bits = [];
991
1110
  if (m.git_dirty)
992
1111
  bits.push('git_dirty');
993
- if (m.prompt_contains)
994
- bits.push(`prompt~"${truncate(m.prompt_contains, 24)}"`);
995
- if (m.prompt_matches)
996
- bits.push(`prompt=/${truncate(m.prompt_matches, 24)}/`);
997
- if (m.tool_args_match)
998
- bits.push(`args=/${truncate(m.tool_args_match, 20)}/`);
999
- if (m.cwd_includes) {
1000
- const c = Array.isArray(m.cwd_includes) ? m.cwd_includes.join('|') : m.cwd_includes;
1001
- bits.push(`cwd~${truncate(c, 24)}`);
1002
- }
1003
- if (m.project_has)
1004
- bits.push(`has ${m.project_has}`);
1112
+ const promptContains = manifestText(m.prompt_contains);
1113
+ if (promptContains)
1114
+ bits.push(`prompt~"${truncate(promptContains, 24)}"`);
1115
+ const promptMatches = manifestText(m.prompt_matches);
1116
+ if (promptMatches)
1117
+ bits.push(`prompt=/${truncate(promptMatches, 24)}/`);
1118
+ const argsMatch = manifestText(m.tool_args_match);
1119
+ if (argsMatch)
1120
+ bits.push(`args=/${truncate(argsMatch, 20)}/`);
1121
+ const cwd = manifestList(m.cwd_includes).join('|');
1122
+ if (cwd)
1123
+ bits.push(`cwd~${truncate(cwd, 24)}`);
1124
+ const projectHas = manifestText(m.project_has);
1125
+ if (projectHas)
1126
+ bits.push(`has ${projectHas}`);
1005
1127
  return bits.join(' · ');
1006
1128
  }
1007
1129
  /** Normalize a hook cache shorthand/object to a display ttl ("5m", "1h"); null when uncached. */
@@ -1010,7 +1132,10 @@ function hookCacheTtl(cache) {
1010
1132
  return null;
1011
1133
  if (typeof cache === 'string')
1012
1134
  return cache.replace(/-bg$/, '');
1013
- return String(cache.ttl);
1135
+ // A bare `cache: 5` has no `.ttl`, and `cache: {ttl: {…}}` has a non-scalar
1136
+ // one — String() on either rendered `(undefined cache)` / `([object Object]
1137
+ // cache)`. No ttl to show means no cache tail.
1138
+ return manifestText(cache.ttl) || null;
1014
1139
  }
1015
1140
  /** Compact one-liner for an MCP server: padded transport + the url (http) or command line (stdio). */
1016
1141
  export function summarizeMcp(cfg) {
@@ -1121,6 +1246,11 @@ function loadCentralHookManifest() {
1121
1246
  * data file collapsed into one entry, non-hook files like promptcuts.yaml or
1122
1247
  * README.md filtered out) rather than a naive readdir, so names are clean and
1123
1248
  * join cleanly against the manifest by script basename.
1249
+ *
1250
+ * `description` is intentionally blank: a hook is a script, and the only text a
1251
+ * readdir-based reader could scrape from one is its shebang (the old flat path
1252
+ * surfaced `!/usr/bin/env bash` as a description). The Hooks section shows firing
1253
+ * events via summarizeHook instead, which is the useful signal.
1124
1254
  */
1125
1255
  function repoHookItems(repo) {
1126
1256
  return listHookEntriesFromDir(path.join(repo.root, 'hooks')).map(h => ({
@@ -1177,7 +1307,10 @@ function readDescription(p) {
1177
1307
  let filePath = p;
1178
1308
  try {
1179
1309
  if (fs.statSync(p).isDirectory()) {
1180
- for (const marker of ['SKILL.md', 'WORKFLOW.md', 'AGENT.md', 'README.md']) {
1310
+ // `rule.md` is the directory form of a subrule (SUBRULE_RULE_FILE in
1311
+ // lib/rules/compose.ts); without it the four dir-form subrules on disk
1312
+ // drill in with a blank preview.
1313
+ for (const marker of ['SKILL.md', 'WORKFLOW.md', 'AGENT.md', 'rule.md', 'README.md']) {
1181
1314
  const c = path.join(p, marker);
1182
1315
  if (fs.existsSync(c)) {
1183
1316
  filePath = c;
@@ -9,7 +9,12 @@
9
9
  *
10
10
  * Cross-host is handled one layer up: `--host <h>` routes the whole command over
11
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.
12
+ * written on the host that actually owns the agent. A caller who doesn't already
13
+ * know the host (a detached `agents run --device <h> --no-follow` dispatch) is
14
+ * still resolved automatically: a `target` matching no local/cloud session falls
15
+ * through to the `~/.agents/.cache/hosts/` records `agents hosts ps` reads
16
+ * (RUSH-2366 follow-up — see decideHostTaskRoute in lib/mailbox-target.ts), and
17
+ * the message is rerouted there.
13
18
  *
14
19
  * For local agents, the message is tied to the agent's current open feed block
15
20
  * (if any). The first answer to a block wins: a second concurrent answer is
@@ -7,7 +7,9 @@ import { getTaskById, updateTaskStatus } from '../lib/cloud/store.js';
7
7
  import { resolveProvider } from '../lib/cloud/registry.js';
8
8
  import { mailboxDir, enqueue } from '../lib/mailbox.js';
9
9
  import { getAgentsInvocation } from '../lib/daemon.js';
10
- import { resolveMessageTarget, mailboxIdForActiveSession } from '../lib/mailbox-target.js';
10
+ import { resolveTaskRef } from '../lib/hosts/tasks.js';
11
+ import { reconcileRunningTasks } from '../lib/hosts/reconcile.js';
12
+ import { resolveMessageTarget, mailboxIdForActiveSession, decideHostTaskRoute, } from '../lib/mailbox-target.js';
11
13
  import { blockIdForSession, listBlocks, readBlock, recordAnswer, recordMessageReceipt, } from '../lib/feed.js';
12
14
  import { verifyOperatorIdentity } from '../lib/operator.js';
13
15
  import { resolveAnswerRoute, resumeArgv, } from '../lib/answer-router.js';
@@ -112,6 +114,40 @@ async function deliverViaResume(route, mailboxId) {
112
114
  console.log(chalk.green(`Resumed ${mailboxId} with answer. `) +
113
115
  chalk.dim(route.reason));
114
116
  }
117
+ /**
118
+ * Reroute a message to a detached `agents run --device <host> --no-follow`
119
+ * dispatch (RUSH-2366 follow-up). `getActiveSessions()` never sees these: the
120
+ * live process is on the dispatch's host, not this machine, so the local
121
+ * session resolver in `resolveMessageTarget` reports "no running agent" even
122
+ * while `agents hosts ps` shows the same dispatch running with a live remote
123
+ * pid — the only recovery was kill-and-redispatch, losing all context.
124
+ *
125
+ * Re-spawns `agents message <remoteRef> <text> --host <host>` through
126
+ * `getAgentsInvocation`, so it re-enters via the SAME `--host` REMOTE_PASSTHROUGH
127
+ * choke point (`lib/hosts/passthrough.ts`) any explicit `--host` caller uses,
128
+ * and resolves against the remote box's own active sessions.
129
+ */
130
+ async function deliverViaHostReroute(route, text, opts) {
131
+ const argv = ['message', route.remoteRef, text, '--host', route.host];
132
+ if (opts.from)
133
+ argv.push('--from', opts.from);
134
+ if (opts.as)
135
+ argv.push('--as', opts.as);
136
+ if (opts.surface)
137
+ argv.push('--surface', opts.surface);
138
+ if (opts.ttl)
139
+ argv.push('--ttl', opts.ttl);
140
+ const inv = getAgentsInvocation(argv);
141
+ const child = spawn(inv.command, inv.args, { stdio: 'inherit', env: process.env });
142
+ const code = await new Promise((resolve) => {
143
+ child.on('exit', (c) => resolve(c ?? 1));
144
+ child.on('error', () => resolve(1));
145
+ });
146
+ if (code !== 0) {
147
+ die(`Delivery to '${route.remoteRef}' on host '${route.host}' exited with code ${code}. ` +
148
+ `Tried: agents ${argv.join(' ')}`);
149
+ }
150
+ }
115
151
  /**
116
152
  * `message` is the agent-control plane (RUSH-2123): the answer/keystroke/
117
153
  * injected input a running agent consumes, never a notification a human reads.
@@ -214,8 +250,29 @@ export function registerMessageCommand(program) {
214
250
  die(`"${target}" matches ${res.candidates.length} running agents:\n${lines}\nRe-run with a full id.`);
215
251
  return;
216
252
  }
217
- case 'none':
218
- die(`No running agent or cloud task matches "${target}". List targets with \`agents sessions --active\`.`);
253
+ case 'none': {
254
+ // Not a local/cloud session check whether it's a detached
255
+ // `--device ... --no-follow` dispatch, which `getActiveSessions()`
256
+ // never sees (its live process is on another host). Same records
257
+ // `agents hosts ps` reads, so the two commands never disagree.
258
+ // Heal first, exactly as `hosts stop`/`hosts ps` do: a detached
259
+ // dispatch record never self-updates, so a finished run is still
260
+ // stamped `status:'running'` on disk. Without this we'd route a dead
261
+ // task through an SSH reroute that can only fail, instead of
262
+ // reporting it finished here.
263
+ const onDisk = resolveTaskRef(target);
264
+ const hostRoute = decideHostTaskRoute(onDisk ? reconcileRunningTasks([onDisk])[0] : null, target);
265
+ if (hostRoute.kind === 'reroute') {
266
+ await deliverViaHostReroute(hostRoute, text, opts);
267
+ return;
268
+ }
269
+ if (hostRoute.kind === 'finished') {
270
+ die(`Task '${target}' on host '${hostRoute.host}' already ${hostRoute.status}` +
271
+ (hostRoute.exitCode !== undefined ? ` (exit ${hostRoute.exitCode})` : '') +
272
+ `. Nothing to message. View its output: \`agents hosts logs ${target}\`.`);
273
+ }
274
+ die(`No running agent or cloud task matches "${target}". List targets with \`agents sessions --active\` or \`agents hosts ps\`.`);
275
+ }
219
276
  }
220
277
  });
221
278
  }
@@ -3,6 +3,7 @@ import { type ProjectDef } from '../lib/projects.js';
3
3
  import type { SessionAgentId, SessionMeta, ViewMode } from '../lib/session/types.js';
4
4
  import { type ActiveSession } from '../lib/session/active.js';
5
5
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
6
+ import { type RunMeta } from '../lib/routines.js';
6
7
  import { type PickedSession } from './sessions-picker.js';
7
8
  import { type ToolSearchEnvelope, type ToolProgramCountEnvelope } from '../lib/session/tool-index.js';
8
9
  interface SessionFilterOptions {
@@ -435,7 +436,60 @@ export interface RoutineChoice {
435
436
  }
436
437
  export declare function buildRoutineRunGroups(sessions: SessionMeta[]): RoutineRunGroup[];
437
438
  export declare function buildRoutineChoices(sessions: SessionMeta[], runOnlyNames?: string[]): RoutineChoice[];
439
+ /**
440
+ * Resolve which routine the user is targeting for `--routine`. Returns `null` to
441
+ * ABORT the command (a bad `--routine <name>` printed an error, or the user
442
+ * cancelled the interactive pick). `{ name: null }` means "no routine was
443
+ * selected, don't filter" — the degenerate bare `--routine` piped to a
444
+ * non-interactive sink. `{ name }` is a chosen routine.
445
+ *
446
+ * The name universe is transcripts UNION every routine with a definition or a
447
+ * run record (`safeListJobsWithRuns`), so a command-only or never-ran routine —
448
+ * which produces no `SessionMeta` — still resolves and drills down.
449
+ */
450
+ export declare function selectRoutineTarget(sessions: SessionMeta[], routine: boolean | string, interactive: boolean): Promise<{
451
+ name: string | null;
452
+ } | null>;
438
453
  export declare function filterSessionsByRoutine(sessions: SessionMeta[], routine: boolean | string, interactive: boolean): Promise<SessionMeta[] | null>;
454
+ /** How a run executed — decides whether an agent session is even expected. */
455
+ export type RoutineExecutionKind = 'agent' | 'command' | 'workflow';
456
+ export declare function executionKind(meta: RunMeta): RoutineExecutionKind;
457
+ /** One canonical run record plus the indexed sessions linked to it by run id. */
458
+ export interface RoutineRunEntry {
459
+ meta: RunMeta;
460
+ sessions: SessionMeta[];
461
+ }
462
+ export interface RoutineDrilldown {
463
+ name: string;
464
+ /** Canonical run records (newest first), each with its linked sessions. */
465
+ runs: RoutineRunEntry[];
466
+ /** Sessions whose `routineRunId` matches no local run record — e.g. archived
467
+ * on another host whose run dir this box has not synced. Grouped by run id so
468
+ * they are still shown, honestly separated from the canonical run history. */
469
+ orphanSessions: RoutineRunGroup[];
470
+ /** Count of canonical run records (any status, incl. missed/blocked/skipped). */
471
+ runRecordCount: number;
472
+ /** Count of linked indexed sessions across all runs (+ orphans). */
473
+ linkedSessionCount: number;
474
+ /** True when any run ran an agent or any session is linked — i.e. an agent
475
+ * routine. False for a pure command routine, where no session is expected. */
476
+ isAgentRoutine: boolean;
477
+ }
478
+ /**
479
+ * Reconcile a routine's canonical run records (`listRuns`, local disk) with the
480
+ * indexed sessions for that routine (fleet-wide, keyed by `routineRunId`).
481
+ */
482
+ export declare function buildRoutineDrilldown(name: string, sessions: SessionMeta[]): RoutineDrilldown;
483
+ /**
484
+ * Render the routine drilldown: canonical run history first, each run linked to
485
+ * its indexed agent session(s). This is the run/session seam made visible — a
486
+ * command routine shows its runs and states plainly that no session is expected;
487
+ * an agent routine shows the same runs plus each run's session metadata.
488
+ */
489
+ export declare function printRoutineDrilldown(drill: RoutineDrilldown, liveIndex?: Map<string, ActiveSession>, opts?: {
490
+ hiddenCount?: number;
491
+ hiddenUnmanaged?: number;
492
+ }): void;
439
493
  /** Resolve a session by id/query globally and print its compact preview (no pager).
440
494
  * Backs `--preview` — the fast path for the "peek before resume" hot loop. */
441
495
  export declare function renderSessionPreview(query: string, scope: {
@@ -527,10 +581,6 @@ export declare function buildOverviewGroups(pool: SessionMeta[], perProjectCap:
527
581
  groups: OverviewGroup[];
528
582
  projectCount: number;
529
583
  };
530
- export declare function printRoutineRunOverview(sessions: SessionMeta[], liveIndex: Map<string, ActiveSession> | undefined, opts?: {
531
- hiddenCount?: number;
532
- hiddenUnmanaged?: number;
533
- }): void;
534
584
  /**
535
585
  * Render a resolved session to stdout — the non-follow view behind
536
586
  * `agents logs <sessionId>`. Defaults to the concise `summary` digest (same as