@phnx-labs/agents-cli 1.22.15 → 1.22.16

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 (47) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +3 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +96 -11
  5. package/dist/commands/humans.d.ts +10 -0
  6. package/dist/commands/humans.js +91 -0
  7. package/dist/commands/resume.d.ts +17 -0
  8. package/dist/commands/resume.js +80 -0
  9. package/dist/commands/sessions.d.ts +13 -4
  10. package/dist/commands/sessions.js +37 -19
  11. package/dist/index.js +3 -2
  12. package/dist/lib/channels/send.d.ts +4 -1
  13. package/dist/lib/channels/send.js +15 -7
  14. package/dist/lib/devices/doctor-findings.d.ts +14 -0
  15. package/dist/lib/devices/doctor-findings.js +92 -51
  16. package/dist/lib/exec.d.ts +6 -2
  17. package/dist/lib/exec.js +22 -4
  18. package/dist/lib/hooks.d.ts +4 -2
  19. package/dist/lib/hooks.js +173 -23
  20. package/dist/lib/humans.d.ts +25 -0
  21. package/dist/lib/humans.js +65 -0
  22. package/dist/lib/memory.js +2 -1
  23. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  24. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  25. package/dist/lib/migrate.js +112 -4
  26. package/dist/lib/notify.js +6 -2
  27. package/dist/lib/permissions.js +12 -10
  28. package/dist/lib/projects.d.ts +3 -3
  29. package/dist/lib/projects.js +19 -17
  30. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  31. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  32. package/dist/lib/secrets/bundles.d.ts +11 -0
  33. package/dist/lib/secrets/bundles.js +122 -5
  34. package/dist/lib/session/actor-sidecar.d.ts +5 -2
  35. package/dist/lib/session/actor-sidecar.js +4 -2
  36. package/dist/lib/session/db.d.ts +2 -1
  37. package/dist/lib/session/db.js +19 -3
  38. package/dist/lib/session/types.d.ts +4 -0
  39. package/dist/lib/staleness/writers/sources.d.ts +6 -1
  40. package/dist/lib/staleness/writers/sources.js +93 -7
  41. package/dist/lib/startup/command-registry.d.ts +2 -0
  42. package/dist/lib/startup/command-registry.js +5 -0
  43. package/dist/lib/state.d.ts +2 -0
  44. package/dist/lib/state.js +6 -6
  45. package/dist/lib/types.d.ts +50 -0
  46. package/dist/lib/versions.js +48 -14
  47. package/package.json +1 -1
package/dist/lib/hooks.js CHANGED
@@ -32,12 +32,77 @@ function resolveContainedHookPath(hooksRoot, script) {
32
32
  return null;
33
33
  return resolved;
34
34
  }
35
+ /**
36
+ * Subdirectories under hooks/ that group event families (e.g. session-starts/).
37
+ * Scripts one level down are first-class hooks; their install name remains the
38
+ * file basename so version-home copies stay flat and doctor/diff keep matching.
39
+ */
40
+ const HOOK_GROUP_SKIP_DIRS = new Set(['node_modules', '.git', '.cache']);
35
41
  export function resolveHookScriptPath(script) {
36
42
  const extraDirs = getEnabledExtraRepos().map(e => e.dir);
37
43
  for (const root of [getUserAgentsDir(), ...extraDirs, getSystemAgentsDir()]) {
38
- const resolved = resolveContainedHookPath(path.join(root, 'hooks'), script);
44
+ const hooksRoot = path.join(root, 'hooks');
45
+ const resolved = resolveContainedHookPath(hooksRoot, script);
39
46
  if (resolved)
40
47
  return resolved;
48
+ // Basename fallback: manifests may say `session-starts/foo.sh` or just
49
+ // `foo.sh` while the file lives under a one-level group dir.
50
+ const base = path.basename(script);
51
+ const nested = findHookScriptInGroupDirs(hooksRoot, base);
52
+ if (nested)
53
+ return nested;
54
+ }
55
+ return null;
56
+ }
57
+ /**
58
+ * Find `basename` under hooks/<group>/ (one level). Skips known non-group dirs.
59
+ * Returns the first match in sorted group order for stability.
60
+ */
61
+ function findHookScriptInGroupDirs(hooksRoot, basename) {
62
+ if (!fs.existsSync(hooksRoot))
63
+ return null;
64
+ let entries;
65
+ try {
66
+ entries = fs.readdirSync(hooksRoot).sort();
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ for (const name of entries) {
72
+ if (name.startsWith('.') || HOOK_GROUP_SKIP_DIRS.has(name))
73
+ continue;
74
+ const groupDir = path.join(hooksRoot, name);
75
+ let st;
76
+ try {
77
+ st = fs.lstatSync(groupDir);
78
+ }
79
+ catch {
80
+ continue;
81
+ }
82
+ if (!st.isDirectory() || st.isSymbolicLink())
83
+ continue;
84
+ // Only treat dirs that themselves contain scripts as groups (session-starts/),
85
+ // not fixture-only directory bundles (tests/).
86
+ let hasScript = false;
87
+ try {
88
+ for (const child of fs.readdirSync(groupDir)) {
89
+ if (SCRIPT_EXTENSIONS.has(path.extname(child).toLowerCase())) {
90
+ const cp = path.join(groupDir, child);
91
+ if (fs.existsSync(cp) && fs.statSync(cp).isFile()) {
92
+ hasScript = true;
93
+ break;
94
+ }
95
+ }
96
+ }
97
+ }
98
+ catch {
99
+ continue;
100
+ }
101
+ if (!hasScript)
102
+ continue;
103
+ const candidate = path.join(groupDir, basename);
104
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile())
105
+ return candidate;
41
106
  }
42
107
  return null;
43
108
  }
@@ -383,38 +448,120 @@ function removeHookFiles(dir, name) {
383
448
  }
384
449
  }
385
450
  /**
386
- * List hook entries in a single directory, grouping script + data files by
387
- * basename. Exported so doctor-diff can reuse the same grouping the sync path
388
- * applies; without this, doctor would double-count `foo.sh` and `foo.yaml`.
451
+ * Collect hook-adjacent files from a hooks root: top-level files plus files in
452
+ * one-level group subdirs (e.g. hooks/session-starts/*.sh). Group dirs exist
453
+ * only for layout; the install/list name stays the file basename so sync and
454
+ * doctor keep a flat version-home model.
389
455
  */
390
- export function listHookEntriesFromDir(dir) {
391
- if (!fs.existsSync(dir)) {
392
- return [];
393
- }
456
+ function collectHookFilesFromRoot(dir) {
394
457
  const files = [];
395
- for (const file of fs.readdirSync(dir)) {
396
- const fullPath = path.join(dir, file);
397
- const stat = fs.statSync(fullPath);
398
- if (!stat.isFile())
399
- continue;
400
- const ext = path.extname(file);
401
- const base = path.basename(file, ext);
458
+ const pushFile = (fullPath, fileName, mode) => {
459
+ const ext = path.extname(fileName);
460
+ const base = path.basename(fileName, ext);
402
461
  files.push({
403
- name: file,
462
+ name: fileName,
404
463
  base,
405
464
  ext,
406
465
  fullPath,
407
- isExec: isExecutable(stat.mode),
466
+ isExec: isExecutable(mode),
408
467
  });
468
+ };
469
+ let top;
470
+ try {
471
+ top = fs.readdirSync(dir);
472
+ }
473
+ catch {
474
+ return files;
475
+ }
476
+ for (const file of top) {
477
+ if (file.startsWith('.'))
478
+ continue;
479
+ const fullPath = path.join(dir, file);
480
+ let stat;
481
+ try {
482
+ stat = fs.lstatSync(fullPath);
483
+ }
484
+ catch {
485
+ continue;
486
+ }
487
+ if (stat.isSymbolicLink())
488
+ continue;
489
+ if (stat.isFile()) {
490
+ pushFile(fullPath, file, stat.mode);
491
+ continue;
492
+ }
493
+ if (!stat.isDirectory() || HOOK_GROUP_SKIP_DIRS.has(file))
494
+ continue;
495
+ // One-level event-group layout: hooks/<group>/<script>. Only dirs that
496
+ // contain top-level scripts are groups; fixture-only dirs (tests/) are not.
497
+ let nested;
498
+ try {
499
+ nested = fs.readdirSync(fullPath);
500
+ }
501
+ catch {
502
+ continue;
503
+ }
504
+ const nestedFiles = [];
505
+ let hasScript = false;
506
+ for (const nestedName of nested) {
507
+ if (nestedName.startsWith('.'))
508
+ continue;
509
+ const nestedPath = path.join(fullPath, nestedName);
510
+ let nstat;
511
+ try {
512
+ nstat = fs.lstatSync(nestedPath);
513
+ }
514
+ catch {
515
+ continue;
516
+ }
517
+ if (nstat.isSymbolicLink() || !nstat.isFile())
518
+ continue;
519
+ nestedFiles.push({ nestedName, nestedPath, mode: nstat.mode });
520
+ if (SCRIPT_EXTENSIONS.has(path.extname(nestedName).toLowerCase()))
521
+ hasScript = true;
522
+ }
523
+ if (!hasScript)
524
+ continue;
525
+ for (const n of nestedFiles)
526
+ pushFile(n.nestedPath, n.nestedName, n.mode);
409
527
  }
410
- const grouped = new Map();
528
+ return files;
529
+ }
530
+ /**
531
+ * List hook entries in a single directory, grouping script + data files by
532
+ * basename. Also discovers scripts in one-level group subdirs
533
+ * (hooks/session-starts/). Exported so doctor-diff can reuse the same grouping
534
+ * the sync path applies; without this, doctor would double-count `foo.sh` and
535
+ * `foo.yaml`. On basename collision, the top-level file wins over a nested one.
536
+ */
537
+ export function listHookEntriesFromDir(dir) {
538
+ if (!fs.existsSync(dir)) {
539
+ return [];
540
+ }
541
+ const files = collectHookFilesFromRoot(dir);
542
+ // Prefer top-level over nested when basenames collide (stable install name).
543
+ const byBase = new Map();
411
544
  for (const file of files) {
412
- const list = grouped.get(file.base) || [];
413
- list.push(file);
414
- grouped.set(file.base, list);
545
+ const list = byBase.get(file.base) || [];
546
+ // Top-level files sit directly under dir; nested have an extra path segment.
547
+ const isTop = path.dirname(file.fullPath) === path.resolve(dir);
548
+ if (isTop)
549
+ list.unshift(file);
550
+ else
551
+ list.push(file);
552
+ byBase.set(file.base, list);
415
553
  }
416
554
  const entries = [];
417
- for (const [base, group] of grouped) {
555
+ for (const [base, groupAll] of byBase) {
556
+ // Keep only files that share the winning script's directory so a nested
557
+ // data sidecar next to a nested script still pairs, and a top-level
558
+ // winner is not paired with a nested yaml of the same basename.
559
+ const winnerScript = groupAll.find((f) => SCRIPT_EXTENSIONS.has(f.ext.toLowerCase())) ||
560
+ groupAll.find((f) => f.isExec && !NON_SCRIPT_EXTENSIONS.has(f.ext.toLowerCase()));
561
+ if (!winnerScript)
562
+ continue;
563
+ const groupDir = path.dirname(winnerScript.fullPath);
564
+ const group = groupAll.filter((f) => path.dirname(f.fullPath) === groupDir);
418
565
  group.sort((a, b) => a.name.localeCompare(b.name));
419
566
  // A group is a hook only if it has an actual script: a script extension,
420
567
  // OR an executable bit on a file whose extension is not a known data /
@@ -1268,7 +1415,10 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1268
1415
  return resolveContainedHookPath(path.join(overrideRoots[0], 'hooks'), script);
1269
1416
  }
1270
1417
  if (localHooksDir) {
1271
- const local = resolveContainedHookPath(localHooksDir, script);
1418
+ // Prefer the exact relative path, then a flat basename copy (sync flattens
1419
+ // group-dir scripts into the version-home hooks/ root).
1420
+ const local = resolveContainedHookPath(localHooksDir, script) ||
1421
+ resolveContainedHookPath(localHooksDir, path.basename(script));
1272
1422
  if (local)
1273
1423
  return local;
1274
1424
  }
@@ -0,0 +1,25 @@
1
+ import type { HumansConfig, HumanOwner } from './types.js';
2
+ export declare const HUMANS_VERSION: 1;
3
+ export declare const HUMANS_HEADER = "# humans.yaml \u2014 owner identity and notification channels\n# Managed by agents-cli. See: agents humans --help\n";
4
+ /**
5
+ * Read and parse humans.yaml. Returns null when the file does not exist or
6
+ * is not a valid v1 config — never throws.
7
+ */
8
+ export declare function readHumans(): HumansConfig | null;
9
+ /**
10
+ * Write a HumansConfig to ~/.agents/humans.yaml. Creates the file with the
11
+ * canonical header. Overwrites any existing content.
12
+ */
13
+ export declare function writeHumans(config: HumansConfig): void;
14
+ /**
15
+ * Return the effective owner notification config from humans.yaml if
16
+ * available, otherwise return null (caller falls back to agents.yaml).
17
+ */
18
+ export declare function getOwnerNotifyFromHumans(): {
19
+ channel: string;
20
+ to: string;
21
+ } | null;
22
+ /**
23
+ * Read the owner block from humans.yaml. Returns null if missing.
24
+ */
25
+ export declare function getOwnerFromHumans(): HumanOwner | null;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * humans.yaml — owner identity, channels, and notification policy.
3
+ *
4
+ * Canonical file: ~/.agents/humans.yaml
5
+ * Schema version: 1
6
+ *
7
+ * This module is the single read/write seam for humans.yaml. All channel/
8
+ * notify consumers should read owner config from here (with a fallback to
9
+ * the legacy notify.owner in agents.yaml during the migration window).
10
+ */
11
+ import * as fs from 'fs';
12
+ import * as yaml from 'yaml';
13
+ import { getHumansFilePath } from './state.js';
14
+ export const HUMANS_VERSION = 1;
15
+ export const HUMANS_HEADER = `# humans.yaml — owner identity and notification channels
16
+ # Managed by agents-cli. See: agents humans --help
17
+ `;
18
+ /**
19
+ * Read and parse humans.yaml. Returns null when the file does not exist or
20
+ * is not a valid v1 config — never throws.
21
+ */
22
+ export function readHumans() {
23
+ const filePath = getHumansFilePath();
24
+ if (!fs.existsSync(filePath))
25
+ return null;
26
+ try {
27
+ const raw = fs.readFileSync(filePath, 'utf-8');
28
+ const parsed = yaml.parse(raw);
29
+ if (!parsed || typeof parsed !== 'object')
30
+ return null;
31
+ const doc = parsed;
32
+ if (doc['version'] !== HUMANS_VERSION)
33
+ return null;
34
+ return doc;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ /**
41
+ * Write a HumansConfig to ~/.agents/humans.yaml. Creates the file with the
42
+ * canonical header. Overwrites any existing content.
43
+ */
44
+ export function writeHumans(config) {
45
+ const filePath = getHumansFilePath();
46
+ const body = yaml.stringify(config, { lineWidth: 120 });
47
+ fs.writeFileSync(filePath, HUMANS_HEADER + body, { encoding: 'utf-8', mode: 0o600 });
48
+ }
49
+ /**
50
+ * Return the effective owner notification config from humans.yaml if
51
+ * available, otherwise return null (caller falls back to agents.yaml).
52
+ */
53
+ export function getOwnerNotifyFromHumans() {
54
+ const humans = readHumans();
55
+ const notify = humans?.owner?.notify;
56
+ if (notify?.channel && notify?.to)
57
+ return notify;
58
+ return null;
59
+ }
60
+ /**
61
+ * Read the owner block from humans.yaml. Returns null if missing.
62
+ */
63
+ export function getOwnerFromHumans() {
64
+ return readHumans()?.owner ?? null;
65
+ }
@@ -46,8 +46,9 @@ export function ensureUserMemoryDir() {
46
46
  }
47
47
  return dir;
48
48
  }
49
+ const RULE_FILE_NAMES = new Set(['memory.md', 'agents.md', 'claude.md', 'gemini.md', 'readme.md']);
49
50
  function isFactFile(name) {
50
- return name.endsWith('.md') && name.toLowerCase() !== 'memory.md';
51
+ return name.endsWith('.md') && !RULE_FILE_NAMES.has(name.toLowerCase());
51
52
  }
52
53
  function slugify(name) {
53
54
  return name
@@ -1106,9 +1106,7 @@ function migrateRuntimeToCache() {
1106
1106
  // releases moved into ~/.agents/.cache/plugins/. See issue #20.
1107
1107
  moveDirOnce(path.join(USER_DIR, 'cloud'), path.join(CACHE_DIR, 'cloud'));
1108
1108
  moveDirOnce(path.join(USER_DIR, 'drive'), path.join(CACHE_DIR, 'drive'));
1109
- // terminals/ stays at the top level: the agents-cli IDE extension publishes
1110
- // ~/.agents/terminals/live-terminals.json and would race with the move on
1111
- // VS Code restart. Leave the path where the extension expects it.
1109
+ moveDirOnce(path.join(USER_DIR, 'terminals'), path.join(CACHE_DIR, 'terminals'));
1112
1110
  moveDirOnce(path.join(USER_DIR, 'logs'), path.join(CACHE_DIR, 'logs'));
1113
1111
  moveDirOnce(path.join(USER_DIR, 'companion'), path.join(CACHE_DIR, 'companion'));
1114
1112
  moveDirOnce(path.join(USER_DIR, 'runtime'), path.join(CACHE_DIR, 'state'));
@@ -1633,7 +1631,7 @@ function containsOnlyDsStore(dir) {
1633
1631
  function warnSystemOrphans() {
1634
1632
  const SHIPPED_ALLOWLIST = new Set([
1635
1633
  // resource directories shipped by the npm package
1636
- 'commands', 'hooks', 'skills', 'rules', 'mcp', 'clis', 'permissions', 'subagents', 'profiles', 'agents', 'routines',
1634
+ 'commands', 'hooks', 'skills', 'rules', 'mcp', 'clis', 'permissions', 'subagents', 'profiles', 'agents', 'routines', 'webhooks',
1637
1635
  // top-level metadata files
1638
1636
  'agents.yaml', 'hooks.yaml', 'README.md', 'CHANGELOG.md',
1639
1637
  // git + repo metadata
@@ -2033,6 +2031,115 @@ export function migrateCliDirToClis(agentsDirs) {
2033
2031
  fs.renameSync(src, dest);
2034
2032
  }
2035
2033
  }
2034
+ /**
2035
+ * Migrate owner identity into humans.yaml.
2036
+ *
2037
+ * Sources (both are optional; migration is a no-op when neither exists):
2038
+ * 1. ~/.agents/agents.yaml notify.owner.{channel,to} — short-form notify config.
2039
+ * 2. ~/.agents/owner.md — YAML frontmatter with name/timezone/quiet_hours/channels/policy.
2040
+ *
2041
+ * Writes ~/.agents/humans.yaml (version: 1) when at least one source is
2042
+ * present, then removes the migrated keys. Idempotent: exits immediately when
2043
+ * humans.yaml already exists.
2044
+ */
2045
+ function migrateHumans() {
2046
+ const humansFile = path.join(USER_DIR, 'humans.yaml');
2047
+ if (fs.existsSync(humansFile))
2048
+ return;
2049
+ const agentsYamlPath = path.join(USER_DIR, 'agents.yaml');
2050
+ const ownerMdPath = path.join(USER_DIR, 'owner.md');
2051
+ let notifyOwner;
2052
+ let ownerName;
2053
+ let ownerTimezone;
2054
+ let ownerQuietHours;
2055
+ let ownerDefaultSeverity;
2056
+ let ownerChannels;
2057
+ let ownerPolicy;
2058
+ // 1. Read notify.owner from agents.yaml.
2059
+ if (fs.existsSync(agentsYamlPath)) {
2060
+ try {
2061
+ const raw = fs.readFileSync(agentsYamlPath, 'utf-8');
2062
+ const doc = yaml.parse(raw);
2063
+ const notify = doc?.['notify'];
2064
+ const owner = notify?.['owner'];
2065
+ const channel = typeof owner?.['channel'] === 'string' ? owner['channel'] : undefined;
2066
+ const to = typeof owner?.['to'] === 'string' ? owner['to'] : undefined;
2067
+ if (channel && to) {
2068
+ notifyOwner = { channel, to };
2069
+ }
2070
+ }
2071
+ catch { /* best-effort */ }
2072
+ }
2073
+ // 2. Read YAML frontmatter from owner.md.
2074
+ if (fs.existsSync(ownerMdPath)) {
2075
+ try {
2076
+ const raw = fs.readFileSync(ownerMdPath, 'utf-8');
2077
+ const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2078
+ if (fmMatch) {
2079
+ const fm = yaml.parse(fmMatch[1]);
2080
+ if (fm && typeof fm === 'object') {
2081
+ if (typeof fm['name'] === 'string')
2082
+ ownerName = fm['name'];
2083
+ if (typeof fm['timezone'] === 'string')
2084
+ ownerTimezone = fm['timezone'];
2085
+ if (typeof fm['quiet_hours'] === 'string')
2086
+ ownerQuietHours = fm['quiet_hours'];
2087
+ if (typeof fm['default_severity'] === 'string')
2088
+ ownerDefaultSeverity = fm['default_severity'];
2089
+ if (Array.isArray(fm['channels']))
2090
+ ownerChannels = fm['channels'];
2091
+ if (fm['policy'] && typeof fm['policy'] === 'object')
2092
+ ownerPolicy = fm['policy'];
2093
+ }
2094
+ }
2095
+ }
2096
+ catch { /* best-effort */ }
2097
+ }
2098
+ // Only write if we have at least one piece of owner data.
2099
+ if (!notifyOwner && !ownerName && !ownerTimezone && !ownerQuietHours && !ownerDefaultSeverity && !ownerChannels && !ownerPolicy)
2100
+ return;
2101
+ const humansDoc = { version: 1 };
2102
+ const ownerDoc = {};
2103
+ if (ownerName)
2104
+ ownerDoc['name'] = ownerName;
2105
+ if (ownerTimezone)
2106
+ ownerDoc['timezone'] = ownerTimezone;
2107
+ if (ownerQuietHours)
2108
+ ownerDoc['quiet_hours'] = ownerQuietHours;
2109
+ if (ownerDefaultSeverity)
2110
+ ownerDoc['default_severity'] = ownerDefaultSeverity;
2111
+ if (notifyOwner)
2112
+ ownerDoc['notify'] = notifyOwner;
2113
+ if (ownerChannels)
2114
+ ownerDoc['channels'] = ownerChannels;
2115
+ if (ownerPolicy)
2116
+ ownerDoc['policy'] = ownerPolicy;
2117
+ humansDoc['owner'] = ownerDoc;
2118
+ try {
2119
+ const header = '# humans.yaml — owner identity and notification channels\n# Managed by agents-cli. See: agents humans --help\n';
2120
+ fs.writeFileSync(humansFile, header + yaml.stringify(humansDoc, { lineWidth: 120 }), { encoding: 'utf-8', mode: 0o600 });
2121
+ console.error('Migrated owner config to humans.yaml');
2122
+ }
2123
+ catch (err) {
2124
+ console.error(`humans.yaml migration: could not write (${err.message})`);
2125
+ return;
2126
+ }
2127
+ // Remove notify.owner from agents.yaml after successful migration.
2128
+ if (notifyOwner && fs.existsSync(agentsYamlPath)) {
2129
+ try {
2130
+ const raw = fs.readFileSync(agentsYamlPath, 'utf-8');
2131
+ const doc = yaml.parseDocument(raw);
2132
+ const notifyNode = doc.get('notify');
2133
+ if (notifyNode instanceof yaml.YAMLMap) {
2134
+ notifyNode.delete('owner');
2135
+ if (notifyNode.items.length === 0)
2136
+ doc.delete('notify');
2137
+ }
2138
+ fs.writeFileSync(agentsYamlPath, String(doc), 'utf-8');
2139
+ }
2140
+ catch { /* best-effort — leave the old key if we can't rewrite */ }
2141
+ }
2142
+ }
2036
2143
  /** Run all idempotent migrations. Safe to call multiple times. */
2037
2144
  export async function runMigration() {
2038
2145
  // MUST run first: every other migrator reads SYSTEM_DIR (the new path).
@@ -2043,6 +2150,7 @@ export async function runMigration() {
2043
2150
  cliMigrateDirs.push(projectDotAgents);
2044
2151
  migrateCliDirToClis(cliMigrateDirs);
2045
2152
  migrateAgentsYaml();
2153
+ migrateHumans();
2046
2154
  deleteSystemPromptsJson();
2047
2155
  migrateSystemConfigJson();
2048
2156
  migratePromptcutsIntoHooks();
@@ -1,4 +1,5 @@
1
1
  import { readMeta } from './state.js';
2
+ import { getOwnerNotifyFromHumans } from './humans.js';
2
3
  import { registerBuiltinProviders } from './channels/providers/index.js';
3
4
  import { lookupTransport } from './channels/resolve.js';
4
5
  export function formatUrgentBlockMessage(block) {
@@ -40,7 +41,10 @@ export function buildOpenClawNotifyArgs(text, opts) {
40
41
  */
41
42
  export async function sendToOwner(text, options = {}) {
42
43
  const meta = options.meta ?? readMeta();
43
- const owner = meta.notify?.owner;
44
+ // humans.yaml is the primary source; agents.yaml notify.owner is the fallback.
45
+ const humansOwner = getOwnerNotifyFromHumans();
46
+ const legacyOwner = meta.notify?.owner;
47
+ const owner = humansOwner ?? legacyOwner;
44
48
  const channel = options.channel ?? owner?.channel;
45
49
  const target = options.target ?? owner?.to;
46
50
  if (!channel || !target) {
@@ -48,7 +52,7 @@ export async function sendToOwner(text, options = {}) {
48
52
  ok: false,
49
53
  channel: channel ?? 'unknown',
50
54
  id: target ?? '',
51
- error: 'notify.owner.{channel,to} not set in agents.yaml',
55
+ error: 'notify.owner.{channel,to} not set in humans.yaml or agents.yaml',
52
56
  };
53
57
  }
54
58
  registerBuiltinProviders();
@@ -122,9 +122,9 @@ export function convertDenyToCodexRules(deny) {
122
122
  * Ensure central permissions directory exists.
123
123
  */
124
124
  function ensurePermissionsDir() {
125
- const dir = getUserPermissionsDir();
126
- if (!fs.existsSync(dir)) {
127
- fs.mkdirSync(dir, { recursive: true });
125
+ const groupsDir = path.join(getUserPermissionsDir(), 'groups');
126
+ if (!fs.existsSync(groupsDir)) {
127
+ fs.mkdirSync(groupsDir, { recursive: true });
128
128
  }
129
129
  }
130
130
  /**
@@ -350,7 +350,8 @@ export function listInstalledPermissions() {
350
350
  ensureAgentsDir();
351
351
  const seen = new Set();
352
352
  const results = [];
353
- for (const dir of [getUserPermissionsDir(), getPermissionsDir()]) {
353
+ for (const baseDir of [getUserPermissionsDir(), getPermissionsDir()]) {
354
+ const dir = path.join(baseDir, 'groups');
354
355
  if (!fs.existsSync(dir))
355
356
  continue;
356
357
  try {
@@ -377,10 +378,11 @@ export function listInstalledPermissions() {
377
378
  return results;
378
379
  }
379
380
  /**
380
- * Get a specific permission set by name. Searches user dir first, then system.
381
+ * Get a specific permission set by name. Searches user groups/ dir first, then system groups/.
381
382
  */
382
383
  function getPermissionSet(name) {
383
- for (const dir of [getUserPermissionsDir(), getPermissionsDir()]) {
384
+ for (const baseDir of [getUserPermissionsDir(), getPermissionsDir()]) {
385
+ const dir = path.join(baseDir, 'groups');
384
386
  for (const ext of ['.yml', '.yaml']) {
385
387
  const filePath = safeJoin(dir, name + ext);
386
388
  if (fs.existsSync(filePath)) {
@@ -402,7 +404,7 @@ export function installPermissionSet(sourcePath, name) {
402
404
  if (!set) {
403
405
  return { success: false, error: 'Invalid permission file' };
404
406
  }
405
- const targetPath = safeJoin(getUserPermissionsDir(), name + '.yml');
407
+ const targetPath = safeJoin(path.join(getUserPermissionsDir(), 'groups'), name + '.yml');
406
408
  try {
407
409
  fs.copyFileSync(sourcePath, targetPath);
408
410
  return { success: true };
@@ -416,9 +418,9 @@ export function installPermissionSet(sourcePath, name) {
416
418
  * sets are intentionally not deletable from user commands.
417
419
  */
418
420
  export function removePermissionSet(name) {
419
- const dir = getUserPermissionsDir();
421
+ const groupsDir = path.join(getUserPermissionsDir(), 'groups');
420
422
  for (const ext of ['.yml', '.yaml']) {
421
- const filePath = safeJoin(dir, name + ext);
423
+ const filePath = safeJoin(groupsDir, name + ext);
422
424
  if (fs.existsSync(filePath)) {
423
425
  try {
424
426
  fs.unlinkSync(filePath);
@@ -2025,7 +2027,7 @@ export function exportPermissionsFromPath(filePath) {
2025
2027
  */
2026
2028
  function savePermissionSet(set) {
2027
2029
  ensurePermissionsDir();
2028
- const filePath = safeJoin(getUserPermissionsDir(), set.name + '.yml');
2030
+ const filePath = safeJoin(path.join(getUserPermissionsDir(), 'groups'), set.name + '.yml');
2029
2031
  try {
2030
2032
  const content = yaml.stringify({
2031
2033
  name: set.name,
@@ -132,9 +132,9 @@ export declare function validateProjectDef(raw: unknown, sourceName?: string): P
132
132
  */
133
133
  export declare function loadProjectDef(name: string): ProjectDef | undefined;
134
134
  /**
135
- * List every defined project, sorted by name. Skips (does not throw on) a
136
- * malformed file so one bad definition can't break `projects list`; the loader
137
- * for a single named project stays strict.
135
+ * List every defined project, sorted by name. A missing projects directory is
136
+ * the empty state; malformed definitions and filesystem failures stay loud so
137
+ * CLI callers (including Factory) can show the actual error.
138
138
  */
139
139
  export declare function listProjectDefs(): ProjectDef[];
140
140
  /**
@@ -187,15 +187,18 @@ export function loadProjectDef(name) {
187
187
  try {
188
188
  raw = fs.readFileSync(projectDefPath(name), 'utf8');
189
189
  }
190
- catch {
191
- return undefined; // absent — not a defined project
190
+ catch (error) {
191
+ if (error.code === 'ENOENT') {
192
+ return undefined; // absent — not a defined project
193
+ }
194
+ throw error;
192
195
  }
193
196
  return validateProjectDef(yaml.parse(raw), name);
194
197
  }
195
198
  /**
196
- * List every defined project, sorted by name. Skips (does not throw on) a
197
- * malformed file so one bad definition can't break `projects list`; the loader
198
- * for a single named project stays strict.
199
+ * List every defined project, sorted by name. A missing projects directory is
200
+ * the empty state; malformed definitions and filesystem failures stay loud so
201
+ * CLI callers (including Factory) can show the actual error.
199
202
  */
200
203
  export function listProjectDefs() {
201
204
  let files;
@@ -205,20 +208,17 @@ export function listProjectDefs() {
205
208
  // loader and silently drop the project. One extension, one code path.
206
209
  files = fs.readdirSync(getProjectsDir()).filter((f) => f.endsWith('.yaml'));
207
210
  }
208
- catch {
209
- return [];
211
+ catch (error) {
212
+ if (error.code === 'ENOENT')
213
+ return [];
214
+ throw error;
210
215
  }
211
216
  const out = [];
212
217
  for (const f of files) {
213
218
  const name = f.replace(/\.yaml$/, '');
214
- try {
215
- const def = loadProjectDef(name);
216
- if (def)
217
- out.push(def);
218
- }
219
- catch {
220
- /* malformed — skip in the listing */
221
- }
219
+ const def = loadProjectDef(name);
220
+ if (def)
221
+ out.push(def);
222
222
  }
223
223
  return out.sort((a, b) => a.name.localeCompare(b.name));
224
224
  }
@@ -255,8 +255,10 @@ export function removeProjectDef(name) {
255
255
  fs.unlinkSync(projectDefPath(name));
256
256
  return true;
257
257
  }
258
- catch {
259
- return false;
258
+ catch (error) {
259
+ if (error.code === 'ENOENT')
260
+ return false;
261
+ throw error;
260
262
  }
261
263
  }
262
264
  /**