@phnx-labs/agents-cli 1.22.61 → 1.22.62

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.62
4
+
5
+ - **Owner notifications fan out across the configured normal-severity channels (PHNX-3567).** `agents send --to owner`, deprecated `agents notify`, monitor notifications, and an important feed's owner sink now attempt every addressable entry named by `owner.policy.normal` in `humans.yaml`, instead of silently selecting only the first. Each Rush-backed destination that cannot deliver on a Linux worker forwards its explicit channel and target to a capable Mac, avoiding both shell quoting and policy re-expansion/duplicate sends. Partial failures stay visible while successful channels still deliver; legacy single-channel configs retain their old behavior. Source: `cli/src/lib/humans.ts`, `cli/src/lib/notify.ts`, `cli/src/lib/channels/owner-forward.ts`, `cli/src/lib/feed-broadcast.ts`.
6
+
3
7
  ## 1.22.61
4
8
 
5
9
  - **`agents harness` wizard: model catalog, connection test, and edit matrix (PHNX-2218/2220/2221/2222).** The create/edit wizard now picks the model from the host's own catalog (`getModelCatalog`) with a free-text escape hatch, gates the endpoint step to hosts that actually carry one, and runs a real pre-save connection test — `agents run <name> "say alive in one word" --headless --timeout 60s` classified into pass / auth / endpoint / model — behind a confirm with `--test`/`--no-test`, offering keep / edit / delete on failure rather than saving a broken harness silently. A resolver-sourced `harnessEditable` matrix disables (with a reason) any param the host's API format can't carry. Source: `apps/cli/src/commands/harness.ts`, `apps/cli/src/commands/harness-wizard.ts`, `apps/cli/src/commands/harness-hooks.ts`, `apps/cli/src/lib/harness-connection-test.ts`.
package/README.md CHANGED
@@ -461,6 +461,12 @@ agents feed answer <key> --choice 0 # first answer wins; route over the recorded
461
461
  agents feed post --title "Halfway done" "CI green, watching merge" # title + body
462
462
  ```
463
463
 
464
+ Important posts can reach every owner channel named in
465
+ `~/.agents/humans.yaml` under `owner.policy.normal` (for example iMessage and
466
+ Slack). Owner delivery attempts every selected channel, reports partial
467
+ failures, and safely forwards Rush-backed channels from headless workers to a
468
+ capable Mac; ordinary milestone posts remain record-only unless configured.
469
+
464
470
  Top-level questions and waiting notifications publish one atomic open-block record per session, including the mailbox id, host, runtime, and every answer option. The default view collapses agents under the **outcome** they serve (Linear ticket, PR, worktree slug, or Unassigned) so a 1,100-agent fleet reads as dozens of deliverables. Answered, resumed, and stopped blocks clear automatically; Task subagents are excluded. The rendered reply command uses the same mailbox id with `agents message`, so the decision routes back to the agent that asked it.
465
471
 
466
472
  ### Auto-nudge stalls
@@ -726,7 +726,9 @@ async function broadcastBlock(block, extras, meta, notify = false) {
726
726
  /** One line per sink that ran. Silent when nothing is configured. */
727
727
  function reportBroadcast(outcomes) {
728
728
  for (const o of outcomes) {
729
- if (o.ok)
729
+ if (o.ok && o.error)
730
+ console.error(chalk.yellow(` → ${o.name} partial: ${o.error}`));
731
+ else if (o.ok)
730
732
  console.log(chalk.gray(` → ${o.name}`));
731
733
  else
732
734
  console.error(chalk.yellow(` → ${o.name} failed: ${o.error}`));
@@ -425,8 +425,8 @@ function buildAction(options) {
425
425
  }
426
426
  if (options.notify !== undefined) {
427
427
  // --notify may be a bare flag (notify the owner) or carry a channel that
428
- // overrides notify.owner.channel. Left unset, the send resolves the owner
429
- // channel + target from notify.owner in agents.yaml (one source of truth).
428
+ // selects one channel. Left unset, the send fans out every addressable
429
+ // owner.policy.normal destination from humans.yaml (one source of truth).
430
430
  const channel = typeof options.notify === 'string' ? options.notify : undefined;
431
431
  chosen.push({ type: 'notify', ...(channel ? { notifyChannel: channel } : {}) });
432
432
  }
@@ -620,7 +620,7 @@ export function registerMonitorsCommands(program) {
620
620
  .option('--action-timeout <t>', 'Kill the --run action if it runs longer than this (e.g. 10m)')
621
621
  .option('--postcondition <cmd>', 'Shell command that must exit 0 after a --run/--routine action settles; otherwise the fire records as no-effect, not ok. {event} is replaced with the fired event summary')
622
622
  .option('--routine <name>', 'Fire an existing routine on change')
623
- .option('--notify [channel]', 'Notify the owner (notify.owner); [channel] overrides the owner channel')
623
+ .option('--notify [channel]', 'Notify every normal-policy owner channel; [channel] selects one channel')
624
624
  .option('--webhook-out <url>', 'POST the event to this URL')
625
625
  // PLACEMENT / hygiene
626
626
  .option('--device <name>', 'OWNER (not body placement) — the single machine that evaluates + fires (exactly-once). See docs/concepts.md#placement.')
@@ -44,6 +44,8 @@ async function runSend(positionalText, opts, ownerMode) {
44
44
  const suffix = result.msgId ? chalk.dim(` (${result.msgId})`) : '';
45
45
  const dry = envelope.dryRun ? chalk.dim(' [dry-run]') : '';
46
46
  console.log(chalk.green(`Sent via ${result.channel} → ${result.id}`) + suffix + dry);
47
+ if (result.error)
48
+ console.error(chalk.yellow(`Partial delivery failure: ${result.error}`));
47
49
  }
48
50
  const SHARED_NOTES = `
49
51
  Planes (do not mix them up):
@@ -122,7 +124,8 @@ export function registerSendCommand(program) {
122
124
  notes: `
123
125
  DEPRECATED. notify ≡ send --to owner and still works, but new callers
124
126
  should use "agents feed post" (record + optional broadcast) instead.
125
- Set notify.owner.{channel,to} in agents.yaml once per machine/fleet.
127
+ Set owner.channels + owner.policy.normal in humans.yaml once per fleet.
128
+ Every channel listed in the normal policy receives an owner-addressed send.
126
129
 
127
130
  ${SHARED_NOTES}
128
131
  `,
@@ -14,9 +14,10 @@
14
14
  * This mirrors the SSH reroute `agents message` (decideHostTaskRoute →
15
15
  * runOnPeer) and the sessions fan-out already use for work that lives on another
16
16
  * box: pick a reachable peer from the device registry and run the same `agents`
17
- * verb there. Here the verb is `agents send --to owner`, which resolves the
18
- * peer's own (fleet-synced) owner destination and delivers through its local
19
- * rush — so the owner is addressed once, from the one box that can reach them.
17
+ * verb there. Here the verb is `agents send --channel <channel> --to <target>`.
18
+ * Keeping the destination explicit and delivering through the peer's local rush
19
+ * destination explicit prevents a multi-channel owner policy from expanding a
20
+ * second time on the peer and duplicating already-successful channels.
20
21
  *
21
22
  * Best-effort seam: it never throws and never blocks the post. When no capable
22
23
  * peer is reachable it resolves `undefined` and the caller keeps its original
@@ -63,13 +64,17 @@ export declare function planOwnerForward(channel: string, meta: Meta, devices: D
63
64
  }): OwnerForwardPlan;
64
65
  /**
65
66
  * Deliver `text` to the owner FROM one peer over SSH. Runs the peer's own
66
- * `agents send --to owner --text <text> --json`, which resolves that box's
67
- * fleet-synced owner destination and delivers through its local provider.
67
+ * `agents send --channel <channel> --to <target> --text <text> --json`, which
68
+ * delivers the already-resolved destination through its local provider.
68
69
  * Resolves the parsed `SendResult`, or `undefined` when the peer is
69
70
  * unreachable / not a dialable device / answered with unparseable output —
70
71
  * every one of which means "try the next peer".
71
72
  */
72
- export type PeerOwnerSender = (machine: string, text: string) => Promise<SendResult | undefined>;
73
+ export interface PeerOwnerEnvelope {
74
+ thread?: string;
75
+ from?: string;
76
+ }
77
+ export type PeerOwnerSender = (machine: string, text: string, channel: string, target: string, envelope?: PeerOwnerEnvelope) => Promise<SendResult | undefined>;
73
78
  /**
74
79
  * Try each capable peer in order and return the first successful delivery. A
75
80
  * peer that is unreachable or reports its own delivery failure is skipped and
@@ -79,10 +84,11 @@ export type PeerOwnerSender = (machine: string, text: string) => Promise<SendRes
79
84
  *
80
85
  * The transport (`send`) is injectable so the try-order / first-success / stop
81
86
  * orchestration is testable without a live SSH host; the default runs the real
82
- * `agents send --to owner` over SSH.
87
+ * explicit-destination `agents send` over SSH.
83
88
  */
84
- export declare function forwardOwnerNotifyToPeer(text: string, channel: string, meta: Meta, opts?: {
89
+ export declare function forwardOwnerNotifyToPeer(text: string, channel: string, target: string, meta: Meta, opts?: {
85
90
  self?: string;
86
91
  devices?: DeviceProfile[];
87
92
  send?: PeerOwnerSender;
93
+ envelope?: PeerOwnerEnvelope;
88
94
  }): Promise<SendResult | undefined>;
@@ -51,11 +51,15 @@ export function planOwnerForward(channel, meta, devices, self, opts = {}) {
51
51
  return { candidates: [], skip: 'no-capable-peer' };
52
52
  return { candidates };
53
53
  }
54
- async function sendOnPeer(machine, text) {
54
+ async function sendOnPeer(machine, text, channel, target, envelope = {}) {
55
55
  const peer = await resolvePeerTarget(machine);
56
56
  if (!peer)
57
57
  return undefined;
58
- const args = ['send', '--to', 'owner', '--text', text, '--json'];
58
+ const args = ['send', '--channel', channel, '--to', target, '--text', text, '--json'];
59
+ if (envelope.thread)
60
+ args.push('--thread', envelope.thread);
61
+ if (envelope.from)
62
+ args.push('--from', envelope.from);
59
63
  // Reuse the one injection-tested remote-command builder every `--device`
60
64
  // dispatch uses (posix `bash -lc` / Windows `-EncodedCommand`), rather than a
61
65
  // second hand-rolled quoting path on a security-sensitive seam. The env map is
@@ -83,9 +87,9 @@ async function sendOnPeer(machine, text) {
83
87
  *
84
88
  * The transport (`send`) is injectable so the try-order / first-success / stop
85
89
  * orchestration is testable without a live SSH host; the default runs the real
86
- * `agents send --to owner` over SSH.
90
+ * explicit-destination `agents send` over SSH.
87
91
  */
88
- export async function forwardOwnerNotifyToPeer(text, channel, meta, opts = {}) {
92
+ export async function forwardOwnerNotifyToPeer(text, channel, target, meta, opts = {}) {
89
93
  // Cheap, I/O-free gate first: a box that already received a forward, or an
90
94
  // owner channel that isn't the macOS-only rush family, can never forward — so
91
95
  // a normal local success/failure never pays a device-registry disk read.
@@ -108,7 +112,7 @@ export async function forwardOwnerNotifyToPeer(text, channel, meta, opts = {}) {
108
112
  return undefined;
109
113
  const send = opts.send ?? sendOnPeer;
110
114
  for (const machine of plan.candidates) {
111
- const result = await send(machine, text);
115
+ const result = await send(machine, text, channel, target, opts.envelope);
112
116
  if (result?.ok)
113
117
  return result;
114
118
  }
@@ -33,6 +33,8 @@ export interface SendResult {
33
33
  attachments?: string[];
34
34
  /** Mailbox provider returns the enqueued message id. */
35
35
  msgId?: string;
36
+ /** Per-destination results when the owner policy selects multiple channels. */
37
+ deliveries?: SendResult[];
36
38
  }
37
39
  export interface ChannelProvider {
38
40
  /** Stable name used in `--channel` and as a `notify.transports` value. */
@@ -1,6 +1,7 @@
1
1
  import { getOwnerNotifyFromHumans } from '../humans.js';
2
2
  import { registerBuiltinProviders } from './providers/index.js';
3
3
  import { resolveTransport } from './resolve.js';
4
+ import { sendToOwner } from '../notify.js';
4
5
  const OWNER_ALIAS = 'owner';
5
6
  /** True when the destination token means “the configured owner”. */
6
7
  export function isOwnerAlias(to) {
@@ -112,6 +113,17 @@ export async function sendMessage(input, meta) {
112
113
  const resolved = resolveSendEnvelope(input, meta);
113
114
  if (!resolved.ok)
114
115
  return { error: resolved.error };
115
- const result = await deliverEnvelope(resolved.envelope, meta);
116
+ const ownerPolicyRequest = resolved.envelope.ownerScoped === true
117
+ && !input.channel?.trim()
118
+ && (!input.to?.trim() || isOwnerAlias(input.to));
119
+ const result = ownerPolicyRequest
120
+ ? await sendToOwner(resolved.envelope.text, {
121
+ meta,
122
+ dryRun: resolved.envelope.dryRun,
123
+ thread: resolved.envelope.thread,
124
+ attachments: resolved.envelope.attachments,
125
+ from: resolved.envelope.from,
126
+ })
127
+ : await deliverEnvelope(resolved.envelope, meta);
116
128
  return { result, envelope: resolved.envelope };
117
129
  }
@@ -36,8 +36,8 @@
36
36
  import { spawnSync } from 'child_process';
37
37
  import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } from './channels/send.js';
38
38
  import { lookupTransport } from './channels/resolve.js';
39
- import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
40
39
  import { registerBuiltinProviders } from './channels/providers/index.js';
40
+ import { sendToOwner } from './notify.js';
41
41
  const LEVEL_RANK = { milestone: 0, important: 1 };
42
42
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
43
43
  export function parseFeedPostLevel(raw) {
@@ -462,6 +462,10 @@ async function runChannelSink(sink, meta) {
462
462
  // for a name that is, in fact, registered.
463
463
  registerBuiltinProviders();
464
464
  const owner = isOwnerAlias(sink.channel);
465
+ if (owner) {
466
+ const result = await sendToOwner(sink.text ?? '', { meta });
467
+ return { name, ok: result.ok, ...(result.error ? { error: result.error } : {}) };
468
+ }
465
469
  const resolved = resolveSendEnvelope({
466
470
  text: sink.text ?? '',
467
471
  channel: owner ? undefined : sink.channel,
@@ -476,17 +480,6 @@ async function runChannelSink(sink, meta) {
476
480
  const result = await deliverEnvelope(resolved.envelope, meta);
477
481
  if (result.ok)
478
482
  return { name, ok: true };
479
- // The owner sink failed locally. When this box structurally cannot reach the
480
- // owner (the rush-backed channel is macOS-only, so a headless Linux worker can
481
- // never ring the phone — PHNX-3303), hand the delivery to a capable fleet peer
482
- // over SSH rather than stranding the important post. Only the `owner` alias
483
- // forwards: it resolves the peer's own fleet-synced owner destination, so a
484
- // non-owner channel sink with an explicit recipient stays local.
485
- if (owner) {
486
- const forwarded = await forwardOwnerNotifyToPeer(sink.text ?? '', resolved.envelope.channel, meta);
487
- if (forwarded?.ok)
488
- return { name, ok: true };
489
- }
490
483
  return { name, ok: false, error: result.error };
491
484
  }
492
485
  /**
@@ -20,6 +20,15 @@ export declare function getOwnerNotifyFromHumans(): {
20
20
  channel: string;
21
21
  to: string;
22
22
  } | null;
23
+ /**
24
+ * Return every addressable owner destination selected by the normal-severity
25
+ * policy, in policy order. A config without a policy keeps the historical
26
+ * single-channel behavior by selecting the first addressable channel.
27
+ */
28
+ export declare function getOwnerNotifyDestinationsFromHumans(): Array<{
29
+ channel: string;
30
+ to: string;
31
+ }>;
23
32
  /**
24
33
  * Read the owner block from humans.yaml. Returns null if missing.
25
34
  */
@@ -51,19 +51,40 @@ export function writeHumans(config) {
51
51
  * policy is declared, the first addressable channel is the default.
52
52
  */
53
53
  export function getOwnerNotifyFromHumans() {
54
+ return getOwnerNotifyDestinationsFromHumans()[0] ?? null;
55
+ }
56
+ /**
57
+ * Return every addressable owner destination selected by the normal-severity
58
+ * policy, in policy order. A config without a policy keeps the historical
59
+ * single-channel behavior by selecting the first addressable channel.
60
+ */
61
+ export function getOwnerNotifyDestinationsFromHumans() {
54
62
  const owner = readHumans()?.owner;
55
63
  const channels = owner?.channels ?? [];
56
64
  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 };
65
+ const selected = preferredIds.length > 0
66
+ ? preferredIds.map((id) => channels.find((entry) => entry.id === id))
67
+ : [channels.find((entry) => entry.to)];
68
+ const seen = new Set();
69
+ const destinations = selected
70
+ .filter((entry) => Boolean(entry?.id && entry.to))
71
+ .map((entry) => ({ channel: entry.id, to: entry.to }))
72
+ .filter((entry) => {
73
+ const key = `${entry.channel}\0${entry.to}`;
74
+ if (seen.has(key))
75
+ return false;
76
+ seen.add(key);
77
+ return true;
78
+ });
79
+ if (destinations.length > 0)
80
+ return destinations;
81
+ const firstAddressable = channels.find((entry) => entry.id && entry.to);
82
+ if (firstAddressable?.to)
83
+ return [{ channel: firstAddressable.id, to: firstAddressable.to }];
63
84
  const migrated = owner?.notify;
64
85
  if (migrated?.channel && migrated.to)
65
- return migrated;
66
- return null;
86
+ return [migrated];
87
+ return [];
67
88
  }
68
89
  /**
69
90
  * Read the owner block from humans.yaml. Returns null if missing.
@@ -25,6 +25,9 @@ export interface OwnerNotifyOptions {
25
25
  target?: string;
26
26
  /** Resolve + build the delivery but do not actually send. */
27
27
  dryRun?: boolean;
28
+ thread?: string;
29
+ attachments?: string[];
30
+ from?: string;
28
31
  }
29
32
  export interface NotifyResult {
30
33
  ok: boolean;
@@ -1,5 +1,5 @@
1
1
  import { readMeta } from './state.js';
2
- import { getOwnerNotifyFromHumans } from './humans.js';
2
+ import { getOwnerNotifyDestinationsFromHumans } from './humans.js';
3
3
  import { registerBuiltinProviders } from './channels/providers/index.js';
4
4
  import { lookupTransport } from './channels/resolve.js';
5
5
  import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
@@ -49,33 +49,75 @@ export function buildOpenClawNotifyArgs(text, opts) {
49
49
  */
50
50
  export async function sendToOwner(text, options = {}) {
51
51
  const meta = options.meta ?? readMeta();
52
- const owner = getOwnerNotifyFromHumans() ?? meta.notify?.owner;
53
- const channel = options.channel ?? owner?.channel;
54
- const target = options.target ?? owner?.to;
55
- if (!channel || !target) {
52
+ const canonical = getOwnerNotifyDestinationsFromHumans();
53
+ const legacy = meta.notify?.owner ? [meta.notify.owner] : [];
54
+ const configured = canonical.length > 0 ? canonical : legacy;
55
+ const destinations = options.target
56
+ ? [{ channel: options.channel ?? configured[0]?.channel, to: options.target }]
57
+ : options.channel
58
+ ? [configured.find((dest) => dest.channel === options.channel) ?? {
59
+ channel: options.channel,
60
+ to: configured[0]?.to,
61
+ }]
62
+ : configured;
63
+ const addressable = destinations.filter((dest) => Boolean(dest.channel && dest.to));
64
+ if (addressable.length === 0) {
56
65
  return {
57
66
  ok: false,
58
- channel: channel ?? 'unknown',
59
- id: target ?? '',
67
+ channel: options.channel ?? 'unknown',
68
+ id: options.target ?? '',
60
69
  error: 'No addressable owner channel configured in humans.yaml or legacy notify.owner',
61
70
  };
62
71
  }
63
72
  registerBuiltinProviders();
64
- const { provider, error } = lookupTransport(channel, meta);
65
- if (!provider) {
66
- return { ok: false, channel, id: target, error };
73
+ const deliveries = [];
74
+ for (const { channel, to: target } of addressable) {
75
+ const { provider, error } = lookupTransport(channel, meta);
76
+ let result;
77
+ try {
78
+ result = provider
79
+ ? await provider.send(text, {
80
+ target,
81
+ ownerScoped: options.target === undefined,
82
+ dryRun: options.dryRun,
83
+ thread: options.thread,
84
+ attachments: options.attachments,
85
+ from: options.from,
86
+ })
87
+ : { ok: false, channel, id: target, error };
88
+ }
89
+ catch (err) {
90
+ result = { ok: false, channel, id: target, error: err.message };
91
+ }
92
+ // A dry-run never delivers, and an override target is an explicit recipient
93
+ // (not the fleet-wide owner) — neither should hop to a peer.
94
+ if (!result.ok && !options.dryRun && options.target === undefined) {
95
+ if (options.attachments?.length) {
96
+ result = {
97
+ ...result,
98
+ error: `${result.error ?? 'local delivery failed'}; owner attachments cannot be forwarded to another device`,
99
+ };
100
+ }
101
+ else {
102
+ result = await forwardOwnerNotifyToPeer(text, channel, target, meta, {
103
+ envelope: { thread: options.thread, from: options.from },
104
+ }) ?? result;
105
+ }
106
+ }
107
+ deliveries.push(result);
67
108
  }
68
- const local = await provider.send(text, {
69
- target,
70
- ownerScoped: options.target === undefined,
71
- dryRun: options.dryRun,
72
- });
73
- // A dry-run never delivers, and an override target is an explicit recipient
74
- // (not the fleet-wide owner) — neither should hop to a peer.
75
- if (local.ok || options.dryRun || options.target !== undefined)
76
- return local;
77
- const forwarded = await forwardOwnerNotifyToPeer(text, channel, meta);
78
- return forwarded ?? local;
109
+ if (deliveries.length === 1)
110
+ return deliveries[0];
111
+ const failures = deliveries.filter((result) => !result.ok);
112
+ return {
113
+ ok: deliveries.some((result) => result.ok),
114
+ channel: 'owner',
115
+ id: deliveries.map((result) => `${result.channel}:${result.id}`).join(','),
116
+ ...(failures.length > 0
117
+ ? { error: failures.map((result) => `${result.channel}: ${result.error ?? 'failed'}`).join('; ') }
118
+ : {}),
119
+ deliveries,
120
+ };
79
121
  }
80
122
  export async function notifyUrgentBlock(block, options = {}) {
81
123
  if (block.notifiedAt) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.61",
3
+ "version": "1.22.62",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",