@phnx-labs/agents-cli 1.22.15 → 1.22.17

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 (66) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.d.ts +0 -1
  5. package/dist/commands/cloud.js +19 -185
  6. package/dist/commands/doctor.js +16 -14
  7. package/dist/commands/exec.js +153 -16
  8. package/dist/commands/feed.d.ts +1 -0
  9. package/dist/commands/feed.js +36 -32
  10. package/dist/commands/humans.d.ts +10 -0
  11. package/dist/commands/humans.js +91 -0
  12. package/dist/commands/resume.d.ts +17 -0
  13. package/dist/commands/resume.js +80 -0
  14. package/dist/commands/run-cloud.d.ts +26 -0
  15. package/dist/commands/run-cloud.js +162 -0
  16. package/dist/commands/sessions.d.ts +13 -4
  17. package/dist/commands/sessions.js +37 -19
  18. package/dist/commands/versions.js +4 -2
  19. package/dist/commands/view.js +1 -54
  20. package/dist/index.js +3 -2
  21. package/dist/lib/agents.js +4 -2
  22. package/dist/lib/channels/send.d.ts +5 -2
  23. package/dist/lib/channels/send.js +16 -13
  24. package/dist/lib/cloud/dispatch.d.ts +27 -0
  25. package/dist/lib/cloud/dispatch.js +214 -0
  26. package/dist/lib/devices/doctor-findings.d.ts +14 -0
  27. package/dist/lib/devices/doctor-findings.js +92 -51
  28. package/dist/lib/exec.d.ts +6 -2
  29. package/dist/lib/exec.js +22 -4
  30. package/dist/lib/feed-post.d.ts +2 -2
  31. package/dist/lib/feed-post.js +15 -10
  32. package/dist/lib/hooks.d.ts +4 -2
  33. package/dist/lib/hooks.js +173 -23
  34. package/dist/lib/hosts/remote-cmd.js +8 -0
  35. package/dist/lib/humans.d.ts +26 -0
  36. package/dist/lib/humans.js +73 -0
  37. package/dist/lib/memory.js +2 -1
  38. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  39. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  40. package/dist/lib/migrate.js +112 -4
  41. package/dist/lib/notify.d.ts +4 -4
  42. package/dist/lib/notify.js +4 -3
  43. package/dist/lib/permissions.js +12 -10
  44. package/dist/lib/placement.d.ts +8 -4
  45. package/dist/lib/placement.js +14 -8
  46. package/dist/lib/projects.d.ts +3 -3
  47. package/dist/lib/projects.js +19 -17
  48. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  49. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  50. package/dist/lib/secrets/bundles.d.ts +11 -0
  51. package/dist/lib/secrets/bundles.js +122 -5
  52. package/dist/lib/session/actor-sidecar.d.ts +5 -2
  53. package/dist/lib/session/actor-sidecar.js +4 -2
  54. package/dist/lib/session/db.d.ts +2 -1
  55. package/dist/lib/session/db.js +19 -3
  56. package/dist/lib/session/types.d.ts +4 -0
  57. package/dist/lib/settings-manifest.js +7 -1
  58. package/dist/lib/staleness/writers/sources.d.ts +6 -1
  59. package/dist/lib/staleness/writers/sources.js +93 -7
  60. package/dist/lib/startup/command-registry.d.ts +2 -0
  61. package/dist/lib/startup/command-registry.js +5 -0
  62. package/dist/lib/state.d.ts +2 -0
  63. package/dist/lib/state.js +6 -6
  64. package/dist/lib/types.d.ts +52 -0
  65. package/dist/lib/versions.js +48 -14
  66. package/package.json +1 -1
@@ -16,7 +16,7 @@
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { spawnSync } from 'child_process';
19
- import { appendActivityEvent, } from './activity.js';
19
+ import { appendActivityEvent, readRecentActivity, } from './activity.js';
20
20
  import { resolveProjectNameForCwd, listProjectDefs } from './projects.js';
21
21
  import { getHistoryDir } from './state.js';
22
22
  import { machineId } from './machine-id.js';
@@ -30,7 +30,7 @@ export const STATUS_TITLE_MAX_CHARS = 60;
30
30
  * Resolve who is posting. Order:
31
31
  * 1. Explicit --session flag
32
32
  * 2. Env: AGENT_SESSION_ID / AGENTS_SESSION_ID / basename(AGENTS_MAILBOX_DIR)
33
- * 3. Env AGENT_LAUNCH_ID → match in pid registry
33
+ * 3. Env AGENT_LAUNCH_ID → match in pid registry or activity index
34
34
  * 4. Walk parent PIDs from startPid (default process.ppid) through by-pid registry
35
35
  */
36
36
  export function resolvePostIdentity(input) {
@@ -55,8 +55,12 @@ export function resolvePostIdentity(input) {
55
55
  registry = walkPidRegistry(start, getParent, readEntry);
56
56
  }
57
57
  }
58
+ const activity = launchId && !envSession && !registry?.sessionId
59
+ ? readRecentActivity({ root: input.activityRoot, maxBytesPerSession: 64 * 1024 })
60
+ .find((event) => event.launchId === launchId)
61
+ : undefined;
58
62
  // Prefer env session (explicit + managed run), fill gaps from registry.
59
- const sessionId = envSession ?? registry?.sessionId;
63
+ const sessionId = envSession ?? registry?.sessionId ?? activity?.sessionId;
60
64
  if (!sessionId || !isValidMailboxId(sessionId))
61
65
  return undefined;
62
66
  const mailboxFromEnv = mailboxIdFromEnv(env);
@@ -66,17 +70,18 @@ export function resolvePostIdentity(input) {
66
70
  return {
67
71
  sessionId,
68
72
  mailboxId,
69
- host: machineIdFromEnv(env),
70
- runtime: env.AGENTS_RUNTIME?.trim() || 'headless',
73
+ host: activity?.host ?? machineIdFromEnv(env),
74
+ runtime: env.AGENTS_RUNTIME?.trim() || activity?.runtime || 'headless',
71
75
  agent: env.AGENTS_AGENT_NAME?.trim()
72
76
  || registry?.agent
77
+ || activity?.agent
73
78
  || detectAgentKind(env),
74
79
  cwd: input.cwd
75
- ?? (env.AGENTS_CWD?.trim() || registry?.cwd || process.cwd()),
76
- pid: registry?.pid,
77
- launchId: launchId || registry?.launchId,
78
- terminalId: env.AGENT_TERMINAL_ID?.trim() || registry?.terminalId,
79
- tmuxPane: env.TMUX_PANE?.trim() || registry?.tmuxPane,
80
+ ?? (env.AGENTS_CWD?.trim() || registry?.cwd || activity?.cwd || process.cwd()),
81
+ pid: registry?.pid ?? activity?.pid,
82
+ launchId: launchId || registry?.launchId || activity?.launchId,
83
+ terminalId: env.AGENT_TERMINAL_ID?.trim() || registry?.terminalId || activity?.terminalId,
84
+ tmuxPane: env.TMUX_PANE?.trim() || registry?.tmuxPane || activity?.tmuxPane,
80
85
  };
81
86
  }
82
87
  function firstValidId(candidates) {
@@ -58,8 +58,10 @@ export interface DuplicateVersionHook {
58
58
  export declare function inspectDuplicateVersionHooks(cwd?: string): DuplicateVersionHook[];
59
59
  /**
60
60
  * List hook entries in a single directory, grouping script + data files by
61
- * basename. Exported so doctor-diff can reuse the same grouping the sync path
62
- * applies; without this, doctor would double-count `foo.sh` and `foo.yaml`.
61
+ * basename. Also discovers scripts in one-level group subdirs
62
+ * (hooks/session-starts/). Exported so doctor-diff can reuse the same grouping
63
+ * the sync path applies; without this, doctor would double-count `foo.sh` and
64
+ * `foo.yaml`. On basename collision, the top-level file wins over a nested one.
63
65
  */
64
66
  export declare function listHookEntriesFromDir(dir: string): HookEntry[];
65
67
  /**
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
  }
@@ -118,6 +118,14 @@ export const RUN_OPTION_FORWARDING = {
118
118
  bare: 'local-only', // skips the local setup-copy push; lease-only concern
119
119
  tailscale: 'local-only', // --tailscale/--no-tailscale gate the lease net mode; never forwarded
120
120
  copyCreds: 'local-only', // copies creds TO the host before dispatch — local concern only
121
+ // Cloud placement: chosen and dispatched from THIS machine via the provider
122
+ // registry; mutually exclusive with --host (placement conflict dies before
123
+ // dispatch), so these never have a remote argv to ride.
124
+ cloud: 'local-only',
125
+ provider: 'local-only',
126
+ repo: 'local-only',
127
+ branch: 'local-only',
128
+ cloudEnv: 'local-only',
121
129
  authCheck: 'local-only', // --no-auth-check gates the local interactive login preflight; --host runs skip that preflight entirely
122
130
  // The notification must land on the box the PERSON is at — the one that
123
131
  // dispatched — not on a headless worker with no desktop to post to. The local
@@ -0,0 +1,26 @@
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 destination from humans.yaml.
16
+ * The owner's normal-severity policy selects the channel; when no normal
17
+ * policy is declared, the first addressable channel is the default.
18
+ */
19
+ export declare function getOwnerNotifyFromHumans(): {
20
+ channel: string;
21
+ to: string;
22
+ } | null;
23
+ /**
24
+ * Read the owner block from humans.yaml. Returns null if missing.
25
+ */
26
+ export declare function getOwnerFromHumans(): HumanOwner | null;
@@ -0,0 +1,73 @@
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.
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as yaml from 'yaml';
12
+ import { getHumansFilePath } from './state.js';
13
+ export const HUMANS_VERSION = 1;
14
+ export const HUMANS_HEADER = `# humans.yaml — owner identity and notification channels
15
+ # Managed by agents-cli. See: agents humans --help
16
+ `;
17
+ /**
18
+ * Read and parse humans.yaml. Returns null when the file does not exist or
19
+ * is not a valid v1 config — never throws.
20
+ */
21
+ export function readHumans() {
22
+ const filePath = getHumansFilePath();
23
+ if (!fs.existsSync(filePath))
24
+ return null;
25
+ try {
26
+ const raw = fs.readFileSync(filePath, 'utf-8');
27
+ const parsed = yaml.parse(raw);
28
+ if (!parsed || typeof parsed !== 'object')
29
+ return null;
30
+ const doc = parsed;
31
+ if (doc['version'] !== HUMANS_VERSION)
32
+ return null;
33
+ return doc;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /**
40
+ * Write a HumansConfig to ~/.agents/humans.yaml. Creates the file with the
41
+ * canonical header. Overwrites any existing content.
42
+ */
43
+ export function writeHumans(config) {
44
+ const filePath = getHumansFilePath();
45
+ const body = yaml.stringify(config, { lineWidth: 120 });
46
+ fs.writeFileSync(filePath, HUMANS_HEADER + body, { encoding: 'utf-8', mode: 0o600 });
47
+ }
48
+ /**
49
+ * Return the effective owner notification destination from humans.yaml.
50
+ * The owner's normal-severity policy selects the channel; when no normal
51
+ * policy is declared, the first addressable channel is the default.
52
+ */
53
+ export function getOwnerNotifyFromHumans() {
54
+ const owner = readHumans()?.owner;
55
+ const channels = owner?.channels ?? [];
56
+ const preferredIds = owner?.policy?.normal ?? [];
57
+ const preferred = preferredIds
58
+ .map((id) => channels.find((entry) => entry.id === id))
59
+ .find((entry) => entry?.to);
60
+ const selected = preferred ?? channels.find((entry) => entry.to);
61
+ if (selected?.id && selected.to)
62
+ return { channel: selected.id, to: selected.to };
63
+ const migrated = owner?.notify;
64
+ if (migrated?.channel && migrated.to)
65
+ return migrated;
66
+ return null;
67
+ }
68
+ /**
69
+ * Read the owner block from humans.yaml. Returns null if missing.
70
+ */
71
+ export function getOwnerFromHumans() {
72
+ return readHumans()?.owner ?? null;
73
+ }
@@ -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();
@@ -4,7 +4,7 @@
4
4
  * Every human-facing owner notification (feed urgent-block dispatch, monitor
5
5
  * `notify` action, `agents notify`) funnels through the single channel seam:
6
6
  * `lookupTransport(channel, meta).provider.send(text, opts)`. The recipient comes
7
- * from `notify.owner` in agents.yaml — never a hardcoded chat id — so changing the
7
+ * from `humans.yaml` — never a hardcoded chat id — so changing the
8
8
  * owner is honoured by every path at once. `notify.transports` picks the actual
9
9
  * provider per host (rush telegram on zion, openclaw-telegram on mac-mini).
10
10
  * Best-effort: a delivery failure is returned to the caller, never thrown, so a
@@ -19,9 +19,9 @@ import type { SendResult } from './channels/registry.js';
19
19
  export interface OwnerNotifyOptions {
20
20
  /** Config source (defaults to `readMeta()`); lets callers/tests inject it. */
21
21
  meta?: Meta;
22
- /** Override the owner channel from `notify.owner.channel`. */
22
+ /** Override the owner channel resolved from humans.yaml. */
23
23
  channel?: string;
24
- /** Override the owner target from `notify.owner.to`. */
24
+ /** Override the owner target resolved from humans.yaml. */
25
25
  target?: string;
26
26
  /** Resolve + build the delivery but do not actually send. */
27
27
  dryRun?: boolean;
@@ -44,7 +44,7 @@ export declare function buildOpenClawNotifyArgs(text: string, opts: {
44
44
  }): string[];
45
45
  /**
46
46
  * Deliver a message to the configured owner through the one channel seam.
47
- * `channel`/`target` default to `notify.owner.{channel,to}`; `notify.transports`
47
+ * `channel`/`target` default to the normal owner channel in humans.yaml; `notify.transports`
48
48
  * selects the provider per host. A missing owner config or a delivery failure
49
49
  * (e.g. openclaw not on PATH) returns a clean `SendResult` error — never a raw
50
50
  * ENOENT — so callers surface a consistent, best-effort failure.
@@ -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) {
@@ -33,14 +34,14 @@ export function buildOpenClawNotifyArgs(text, opts) {
33
34
  }
34
35
  /**
35
36
  * Deliver a message to the configured owner through the one channel seam.
36
- * `channel`/`target` default to `notify.owner.{channel,to}`; `notify.transports`
37
+ * `channel`/`target` default to the normal owner channel in humans.yaml; `notify.transports`
37
38
  * selects the provider per host. A missing owner config or a delivery failure
38
39
  * (e.g. openclaw not on PATH) returns a clean `SendResult` error — never a raw
39
40
  * ENOENT — so callers surface a consistent, best-effort failure.
40
41
  */
41
42
  export async function sendToOwner(text, options = {}) {
42
43
  const meta = options.meta ?? readMeta();
43
- const owner = meta.notify?.owner;
44
+ const owner = getOwnerNotifyFromHumans() ?? meta.notify?.owner;
44
45
  const channel = options.channel ?? owner?.channel;
45
46
  const target = options.target ?? owner?.to;
46
47
  if (!channel || !target) {
@@ -48,7 +49,7 @@ export async function sendToOwner(text, options = {}) {
48
49
  ok: false,
49
50
  channel: channel ?? 'unknown',
50
51
  id: target ?? '',
51
- error: 'notify.owner.{channel,to} not set in agents.yaml',
52
+ error: 'No addressable owner channel configured in humans.yaml or legacy notify.owner',
52
53
  };
53
54
  }
54
55
  registerBuiltinProviders();