@phnx-labs/agents-cli 1.21.0 → 1.21.1

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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.21.1
4
+
5
+ - **Feed posts require a title + body; phone `{message}` ends with a Sent-from footer.** `agents feed post --title "Short subject" "body text"` — title is the phone first line (~4–5 words), body follows after a blank line, then `Sent from <agent>/<session-chunk> on <host>` (like "Sent from my iPhone"). Em/en dashes in title/body are scrubbed to ASCII ` - `. Source: `apps/cli/src/lib/feed-broadcast.ts`, `feed-post.ts`, `commands/feed.ts`.
6
+
7
+ - **Hook `timeout` in agents.yaml now accepts duration strings, not just bare seconds (#1555).**
8
+ A hook can be written `timeout: 5s` / `timeout: 2m` / `timeout: 1h30m` instead of only
9
+ `timeout: 30` — self-documenting at the call site. A bare number still means seconds, so
10
+ every existing manifest keeps working. `parseHookManifest` normalizes the value to a
11
+ seconds number once, so all harness serializers keep consuming a number; an unparseable
12
+ timeout is dropped with a warning rather than silently coerced. Source:
13
+ `apps/cli/src/lib/hooks.ts` (`normalizeHookTimeoutSeconds`, `parseHookManifest`),
14
+ `apps/cli/docs/hooks.md`.
15
+
16
+ - **Owner notifications route through the one channel seam.** The feed urgent-block
17
+ dispatch and the monitor `notify` action now send through the registered channel
18
+ provider (`lookupTransport` → `ChannelProvider.send`) instead of shelling out to
19
+ `openclaw` directly. The recipient comes from `notify.owner` in agents.yaml — the
20
+ hardcoded owner chat id is gone, so changing `notify.owner` is honoured by every
21
+ path. A bare `--notify` on a monitor now targets `notify.owner`; `--notify <channel>`
22
+ overrides the owner channel. The monitor path also gains the provider's missing-binary
23
+ guard (a clean error instead of a raw ENOENT). A channel name that resolves to no
24
+ registered provider (a typo in `notify.owner.channel`, or `--notify <channel>`) fails
25
+ that one send with a clean error — it does not exit the monitor daemon or abort the
26
+ `agents feed --dispatch` loop. Source: `apps/cli/src/lib/notify.ts`,
27
+ `apps/cli/src/lib/monitors/dispatch.ts`, `apps/cli/src/lib/channels/resolve.ts`.
28
+
3
29
  ## 1.21.0
4
30
 
5
31
  - **A clone of your own DotAgents repo no longer hijacks project-layer rule resolution (RUSH-2037).**
package/README.md CHANGED
@@ -341,7 +341,7 @@ agents feed --flat # one row per agent (legacy)
341
341
  agents feed --host mac-mini # scope the view to one or more hosts
342
342
  agents feed --local # skip the SSH fan-out
343
343
  agents feed --json # blocks stamped with their outcome key
344
- agents feed post "halfway done" # agent status post (auto session identity)
344
+ agents feed post --title "Halfway done" "CI green, watching merge" # title + body
345
345
  ```
346
346
 
347
347
  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.
package/dist/bin/agents CHANGED
Binary file
@@ -234,44 +234,44 @@ export function registerFeedCommand(program) {
234
234
  feed
235
235
  .command('post')
236
236
  .description('Post a status update to the fleet activity stream (for agents)')
237
- .argument('<text...>', 'What just happened one short human line')
237
+ .argument('<text...>', 'Body: what just happened (after --title)')
238
+ .requiredOption('--title <title>', 'Short subject, ~4-5 words (phone first line)')
238
239
  .option('--session <id>', 'Session id escape hatch (default: auto from env / pid registry)')
239
240
  .option('--attach <path-or-url...>', 'Attach an artifact (local file or URL); repeatable')
240
241
  .option('--level <level>', 'How loudly to broadcast: milestone (default) or important. Configured sinks with minLevel: important only fire on the latter.', 'milestone')
241
- .option('--blocked', 'You are STUCK and need the user. Opens an answerable block and always broadcasts at important do not also pass --level.')
242
+ .option('--blocked', 'You are STUCK and need the user. Opens an answerable block and always broadcasts at important - do not also pass --level.')
242
243
  .option('--option <label...>', 'With --blocked: an answer the user can pick; repeatable')
243
244
  .option('--default <answer>', 'With --blocked: a safe default policy may apply if nobody answers in time')
244
245
  .option('--json', 'Emit the written event as JSON')
245
246
  .addHelpText('after', `
246
247
  Examples:
247
- # Inside an agents-cli run (session identity is already in the env):
248
- agents feed post "CHANGELOG pushed; watching CI and mac-mini E2E"
249
- agents feed post "cover render ready" --attach ./out/cover.png
250
- agents feed post "ready for review" --json
248
+ # Title (subject) + body. Phone broadcasts put title first, body after a
249
+ # blank line, then a "Sent from agent/session on host" footer.
250
+ agents feed post --title "CHANGELOG pushed" "Watching CI and mac-mini E2E"
251
+ agents feed post --title "Cover ready" "render at ./out/cover.png" --attach ./out/cover.png
252
+ agents feed post --title "Ready for review" "PR opened, waiting on prix-cloud" --json
251
253
 
252
- # Worth interrupting someone over reaches sinks gated on minLevel: important:
253
- agents feed post "release blocked: npm token expired" --level important
254
+ # Worth interrupting someone over - reaches sinks gated on minLevel: important:
255
+ agents feed post --title "npm token expired" "Cannot publish the release" --level important
254
256
 
255
- # You are STUCK and cannot proceed. Opens an answerable block that stays in
256
- # 'agents feed' until someone resolves it, and always reaches the owner —
257
- # do NOT also pass --level:
258
- agents feed post "force-push denied by git-guard on PR #1749" --blocked
259
- agents feed post "publish to npm or wait for review?" --blocked --option publish --option wait
260
- agents feed post "delete the stale preview env?" --blocked --default "leave it"
257
+ # Stuck: opens a needs-you block and always broadcasts at important:
258
+ agents feed post --title "Force-push denied" "git-guard blocked PR #1749" --blocked
259
+ agents feed post --title "Publish or wait?" "npm publish now or after review" --blocked --option publish --option wait
260
+ agents feed post --title "Delete preview env?" "stale preview still running" --blocked --default "leave it"
261
261
 
262
262
  # Exhaust self-serve FIRST. A block is for what you genuinely cannot do:
263
263
  # a credential only the user holds, a decision only they can make, an
264
264
  # approval only they can give. Not "should I do the obvious next step?".
265
265
 
266
266
  # Outside a run, pass the session explicitly:
267
- agents feed post "manual note" --session 00998b0e-2d15-4d2f-a58b-974a886c9b47
267
+ agents feed post --title "Manual note" "context for the next agent" --session 00998b0e-2d15-4d2f-a58b-974a886c9b47
268
268
 
269
- Identity (session, agent, host, runtime, pid, launchId) is stamped automatically.
270
- Domain facts (tickets, PRs) are not CLI flags the ticket is joined from the
271
- session index at post time, so a broadcast sink can comment on it without the
272
- agent having to remember it.
269
+ Identity (session, agent, host, runtime, pid, launchId) is stamped automatically
270
+ and rides the phone footer of feed.broadcast {message}. Domain facts (tickets,
271
+ PRs) are not CLI flags - the ticket is joined from the session index at post
272
+ time. No em-dashes in title/body - they are scrubbed on the way out.
273
273
 
274
- Configure where a post is mirrored under feed.broadcast in agents.yaml see
274
+ Configure where a post is mirrored under feed.broadcast in agents.yaml - see
275
275
  docs/06-observability.md.
276
276
  `)
277
277
  .action((textParts, opts, cmd) => {
@@ -279,6 +279,7 @@ docs/06-observability.md.
279
279
  // binds the flag on the parent, so a `feed post … --json` lands on
280
280
  // parent.opts().json — not the child. Read both.
281
281
  const flags = {
282
+ title: opts?.title ?? cmd?.opts?.()?.title,
282
283
  session: opts?.session ?? cmd?.opts?.()?.session,
283
284
  attach: opts?.attach ?? cmd?.opts?.()?.attach,
284
285
  level: opts?.level ?? cmd?.opts?.()?.level,
@@ -298,8 +299,12 @@ docs/06-observability.md.
298
299
  if (!flags.blocked && (flags.option?.length || flags.default)) {
299
300
  throw new Error('--option/--default only apply with --blocked.');
300
301
  }
302
+ if (!flags.title?.trim()) {
303
+ throw new Error('Missing --title. Usage: agents feed post --title "Short subject" "body text"');
304
+ }
301
305
  const level = flags.blocked ? 'important' : parseFeedPostLevel(flags.level);
302
306
  const { event } = postFeedStatus({
307
+ title: flags.title,
303
308
  text: Array.isArray(textParts) ? textParts.join(' ') : String(textParts ?? ''),
304
309
  sessionId: flags.session,
305
310
  attach: flags.attach,
@@ -312,12 +317,20 @@ docs/06-observability.md.
312
317
  let outcomes;
313
318
  if (flags.blocked) {
314
319
  const block = buildDeclaredBlock(event, {
315
- text: event.detail ?? '',
320
+ // Prefer title as the front-loaded ask on the phone; body is detail.
321
+ text: event.title
322
+ ? (event.detail ? `${event.title}: ${event.detail}` : event.title)
323
+ : (event.detail ?? ''),
316
324
  options: flags.option,
317
325
  safeDefault: flags.default,
318
326
  });
319
327
  publishBlock(block);
320
- outcomes = broadcastBlock(block, { project: event.project, agent: event.agent });
328
+ outcomes = broadcastBlock(block, {
329
+ project: event.project,
330
+ agent: event.agent,
331
+ title: event.title,
332
+ body: event.detail,
333
+ });
321
334
  }
322
335
  else {
323
336
  outcomes = broadcastPostedEvent(event, level);
@@ -561,6 +574,7 @@ function broadcastPostedEvent(event, level) {
561
574
  return [];
562
575
  const ticket = getSessionById(event.sessionId)?.ticketId;
563
576
  const planned = planFeedBroadcast(config, {
577
+ title: event.title,
564
578
  text: event.detail ?? '',
565
579
  level,
566
580
  ticket,
@@ -675,7 +689,7 @@ function renderUpdatesView(updates) {
675
689
  }));
676
690
  console.log();
677
691
  if (updates.length === 0) {
678
- console.log(chalk.gray(' No progress updates yet. Agents post them with `agents feed post "…"`.'));
692
+ console.log(chalk.gray(' No progress updates yet. Agents post them with `agents feed post --title "…" "…"`.'));
679
693
  return;
680
694
  }
681
695
  for (const ev of updates) {
@@ -57,7 +57,7 @@ function actionLabel(action) {
57
57
  case 'routine':
58
58
  return `routine ${action.routine ?? ''}`;
59
59
  case 'notify':
60
- return `notify ${action.notifyChannel ?? 'telegram'}`;
60
+ return `notify ${action.notifyChannel ?? 'owner'}`;
61
61
  case 'webhook-out':
62
62
  return `webhook-out ${action.url ?? ''}`;
63
63
  default:
@@ -195,9 +195,11 @@ function buildAction(options) {
195
195
  if (options.routine)
196
196
  chosen.push({ type: 'routine', routine: options.routine });
197
197
  if (options.notify !== undefined) {
198
- // --notify may be a bare flag (true) or carry a channel string.
199
- const channel = typeof options.notify === 'string' ? options.notify : 'telegram';
200
- chosen.push({ type: 'notify', notifyChannel: channel });
198
+ // --notify may be a bare flag (notify the owner) or carry a channel that
199
+ // overrides notify.owner.channel. Left unset, the send resolves the owner
200
+ // channel + target from notify.owner in agents.yaml (one source of truth).
201
+ const channel = typeof options.notify === 'string' ? options.notify : undefined;
202
+ chosen.push({ type: 'notify', ...(channel ? { notifyChannel: channel } : {}) });
201
203
  }
202
204
  if (options.webhookOut)
203
205
  chosen.push({ type: 'webhook-out', url: options.webhookOut });
@@ -310,7 +312,7 @@ export function registerMonitorsCommands(program) {
310
312
  .option('--effort <effort>', 'Reasoning effort for --run: low | medium | high | xhigh | max | auto')
311
313
  .option('--action-timeout <t>', 'Kill the --run action if it runs longer than this (e.g. 10m)')
312
314
  .option('--routine <name>', 'Fire an existing routine on change')
313
- .option('--notify [channel]', 'Send a notification (default channel: telegram)')
315
+ .option('--notify [channel]', 'Notify the owner (notify.owner); [channel] overrides the owner channel')
314
316
  .option('--webhook-out <url>', 'POST the event to this URL')
315
317
  // PLACEMENT / hygiene
316
318
  .option('--device <name>', 'OWNER device — the single machine that evaluates + fires (exactly-once)')
@@ -573,7 +575,7 @@ export function registerMonitorsCommands(program) {
573
575
  name,
574
576
  source: { type: 'poll', command: 'echo hello', interval: '1m' },
575
577
  condition: { mode: 'on-change' },
576
- action: { type: 'notify', notifyChannel: 'telegram' },
578
+ action: { type: 'notify' },
577
579
  });
578
580
  fs.writeFileSync(monitorPath, template, 'utf-8');
579
581
  console.log(chalk.gray(`Created new monitor file: ${monitorPath}`));
@@ -89,6 +89,12 @@ export interface ActivityEvent {
89
89
  tool?: string;
90
90
  /** One-line human summary (plan title, PR command, sub-agent role, status text). */
91
91
  detail?: string;
92
+ /**
93
+ * Short subject for deliberate status posts (`feed post --title`). Phone
94
+ * broadcasts put this on the first line; `detail` is the body. Optional on
95
+ * older events that only carried `detail`.
96
+ */
97
+ title?: string;
92
98
  /** Extracted URL when the event has one (e.g. the opened PR). */
93
99
  url?: string;
94
100
  /** Auto-stamped process identity for deliberate posts (from pid registry / env). */
@@ -141,6 +141,7 @@ function parseLine(line) {
141
141
  kind: parsed.kind,
142
142
  tool: parsed.tool,
143
143
  detail: parsed.detail,
144
+ title: typeof parsed.title === 'string' ? parsed.title : undefined,
144
145
  url: parsed.url,
145
146
  pid: typeof parsed.pid === 'number' ? parsed.pid : undefined,
146
147
  launchId: parsed.launchId,
@@ -398,6 +399,8 @@ export function formatProgressUpdate(ev, opts = {}) {
398
399
  ].filter((c) => Boolean(c));
399
400
  if (chips.length > 0)
400
401
  lines.push(` ${chalk.gray(chips.join(' · '))}`);
402
+ if (ev.title)
403
+ lines.push(` ${chalk.white.bold(ev.title)}`);
401
404
  if (ev.detail)
402
405
  lines.push(` ${chalk.white(`"${ev.detail}"`)}`);
403
406
  if (ev.attachments?.length) {
@@ -24,7 +24,7 @@ export const openclawTelegramProvider = {
24
24
  return { ok: false, channel: name, id: opts.target, error: 'openclaw CLI not found on PATH' };
25
25
  }
26
26
  try {
27
- await execFileAsync('openclaw', buildOpenClawNotifyArgs(text, { channel: 'telegram', target: opts.target }));
27
+ await execFileAsync('openclaw', buildOpenClawNotifyArgs(text, { target: opts.target }));
28
28
  return { ok: true, channel: name, id: opts.target };
29
29
  }
30
30
  catch (err) {
@@ -4,8 +4,25 @@
4
4
  *
5
5
  * Default is name-identity (`--channel slack` -> `slack` provider) — NOT a
6
6
  * fallback to a different transport. Only telegram is dual-homed (rush vs
7
- * openclaw-telegram); config picks. An unmapped-to-unregistered name dies loud.
7
+ * openclaw-telegram); config picks.
8
+ *
9
+ * Two entry points, deliberately: `lookupTransport` *returns* the failure, for
10
+ * long-lived callers (the monitor daemon, the feed-dispatch loop) that must
11
+ * survive a bad channel name; `resolveTransport` `die()`s on it, for the
12
+ * interactive `agents send` / `agents notify` command path where exiting with a
13
+ * loud message is the right answer. Never give a daemon the dying one.
8
14
  */
9
15
  import type { Meta } from '../types.js';
10
16
  import { type ChannelProvider } from './registry.js';
17
+ export interface TransportLookup {
18
+ /** Provider name after applying the `notify.transports` mapping. */
19
+ providerName: string;
20
+ /** Registered provider, or undefined when `providerName` resolves to nothing. */
21
+ provider?: ChannelProvider;
22
+ /** Why resolution failed — set exactly when `provider` is undefined. */
23
+ error?: string;
24
+ }
25
+ /** Resolve a channel to its provider, returning the failure instead of exiting. */
26
+ export declare function lookupTransport(channel: string, meta: Meta): TransportLookup;
27
+ /** Interactive-command resolution: an unregistered provider dies loud. */
11
28
  export declare function resolveTransport(channel: string, meta: Meta): ChannelProvider;
@@ -1,12 +1,22 @@
1
1
  import { die } from '../format.js';
2
2
  import { resolveChannelProvider, listChannelProviders } from './registry.js';
3
- export function resolveTransport(channel, meta) {
3
+ /** Resolve a channel to its provider, returning the failure instead of exiting. */
4
+ export function lookupTransport(channel, meta) {
4
5
  const providerName = meta.notify?.transports?.[channel] ?? channel;
5
6
  const provider = resolveChannelProvider(providerName);
6
- if (!provider) {
7
- die(`No channel provider '${providerName}'` +
7
+ if (provider)
8
+ return { providerName, provider };
9
+ return {
10
+ providerName,
11
+ error: `No channel provider '${providerName}'` +
8
12
  (providerName === channel ? '' : ` (mapped from channel '${channel}' via notify.transports)`) +
9
- `. Registered: ${listChannelProviders().join(', ')}.`);
10
- }
13
+ `. Registered: ${listChannelProviders().join(', ')}.`,
14
+ };
15
+ }
16
+ /** Interactive-command resolution: an unregistered provider dies loud. */
17
+ export function resolveTransport(channel, meta) {
18
+ const { provider, error } = lookupTransport(channel, meta);
19
+ if (!provider)
20
+ die(error);
11
21
  return provider;
12
22
  }
@@ -16,7 +16,9 @@ export interface FeedSinkConfig {
16
16
  export type FeedBroadcastConfig = Record<string, FeedSinkConfig>;
17
17
  /** Everything a template may interpolate. Absent values skip templates that need them. */
18
18
  export interface FeedBroadcastContext {
19
- /** The post text, verbatim. */
19
+ /** Short subject line (~4–5 words). Phone line 1. */
20
+ title?: string;
21
+ /** The post body, verbatim. Phone line after the blank line. */
20
22
  text: string;
21
23
  level: FeedPostLevel;
22
24
  /** Tracker id for the work, e.g. `RUSH-2081`. */
@@ -63,6 +65,8 @@ export declare function blockBroadcastContext(block: {
63
65
  }, extras?: {
64
66
  project?: string;
65
67
  agent?: string;
68
+ title?: string;
69
+ body?: string;
66
70
  }): FeedBroadcastContext;
67
71
  /**
68
72
  * Why a declared block reached nobody, or undefined when it got through.
@@ -87,10 +91,42 @@ export interface SinkOutcome {
87
91
  error?: string;
88
92
  }
89
93
  /**
90
- * A human-facing one-liner for a messaging sink: what project, what happened,
91
- * and the link to go read more. Leading with the project is deliberate — a
92
- * message that opens with an agent name tells the reader nothing about which of
93
- * their projects just moved.
94
+ * Short host label for a phone line strip user@ and domain so
95
+ * `muqsit@mac-mini.tailnet.ts.net` reads as `mac-mini`.
96
+ */
97
+ export declare function shortHost(host: string | undefined): string | undefined;
98
+ /** First 8 hex chars of a session id for the footer (readable, not a full uuid). */
99
+ export declare function shortSessionChunk(session: string | undefined): string | undefined;
100
+ /**
101
+ * Scrub em/en dashes from outbound phone copy (house rule + iMessage readability).
102
+ * Collapses whitespace; does not invent meaning.
103
+ */
104
+ export declare function scrubOutboundDashes(text: string): string;
105
+ /**
106
+ * Footer like "Sent from my iPhone" — who posted, a session crumb, which box.
107
+ *
108
+ * Sent from grok/a02da0e2 on mac-mini
109
+ *
110
+ * Agent name first; session chunk for disambiguation when many groks run;
111
+ * host last. Skip the uninformative default label `agent`.
112
+ */
113
+ export declare function composeBroadcastFooter(ctx: FeedBroadcastContext): string | undefined;
114
+ /**
115
+ * Human-facing body for a messaging sink (`{message}`).
116
+ *
117
+ * ```
118
+ * Title in a few words
119
+ *
120
+ * Body of what happened or the ask.
121
+ *
122
+ * Sent from grok/a02da0e2 on mac-mini
123
+ * agents focus a02da0e2 (blocks only)
124
+ * https://… (optional attach URL)
125
+ * ```
126
+ *
127
+ * Title first (scannable subject). Blank line. Body. Footer provenance so a
128
+ * fleet of agents is attributable without crowding the ask. Prefer `{message}`
129
+ * over bare `{text}` in messaging sinks.
94
130
  */
95
131
  export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): string;
96
132
  /**
@@ -50,8 +50,12 @@ export function parseFeedPostLevel(raw) {
50
50
  export function blockBroadcastContext(block, extras = {}) {
51
51
  const ask = block.questions?.[0]?.text?.trim() || 'agent is blocked';
52
52
  const links = [block.pr].filter((l) => !!l && /^https?:\/\//i.test(l));
53
+ // Prefer explicit title/body from the feed post; fall back to the ask as body.
54
+ const title = extras.title?.trim() || undefined;
55
+ const text = extras.body?.trim() || ask;
53
56
  return {
54
- text: ask,
57
+ ...(title ? { title } : {}),
58
+ text,
55
59
  level: 'important',
56
60
  ticket: block.ticket,
57
61
  project: extras.project,
@@ -92,24 +96,118 @@ export function blockDeliveryFailure(blocked, outcomes) {
92
96
  }
93
97
  const PLACEHOLDER = /\{([a-z]+)\}/g;
94
98
  /**
95
- * A human-facing one-liner for a messaging sink: what project, what happened,
96
- * and the link to go read more. Leading with the project is deliberate — a
97
- * message that opens with an agent name tells the reader nothing about which of
98
- * their projects just moved.
99
+ * Short host label for a phone line strip user@ and domain so
100
+ * `muqsit@mac-mini.tailnet.ts.net` reads as `mac-mini`.
101
+ */
102
+ export function shortHost(host) {
103
+ if (!host?.trim())
104
+ return undefined;
105
+ let h = host.trim();
106
+ const at = h.lastIndexOf('@');
107
+ if (at !== -1)
108
+ h = h.slice(at + 1);
109
+ const dot = h.indexOf('.');
110
+ if (dot > 0)
111
+ h = h.slice(0, dot);
112
+ return h || undefined;
113
+ }
114
+ /** First 8 hex chars of a session id for the footer (readable, not a full uuid). */
115
+ export function shortSessionChunk(session) {
116
+ if (!session?.trim())
117
+ return undefined;
118
+ const hex = session.replace(/-/g, '').toLowerCase();
119
+ const chunk = hex.replace(/[^a-f0-9]/g, '').slice(0, 8);
120
+ return chunk || undefined;
121
+ }
122
+ /**
123
+ * Scrub em/en dashes from outbound phone copy (house rule + iMessage readability).
124
+ * Collapses whitespace; does not invent meaning.
125
+ */
126
+ export function scrubOutboundDashes(text) {
127
+ return text
128
+ .replace(/\u2014/g, ' - ')
129
+ .replace(/\u2013/g, ' - ')
130
+ .replace(/[ \t]+\n/g, '\n')
131
+ .replace(/\n{3,}/g, '\n\n')
132
+ .replace(/[ \t]{2,}/g, ' ')
133
+ .trim();
134
+ }
135
+ /**
136
+ * Footer like "Sent from my iPhone" — who posted, a session crumb, which box.
137
+ *
138
+ * Sent from grok/a02da0e2 on mac-mini
139
+ *
140
+ * Agent name first; session chunk for disambiguation when many groks run;
141
+ * host last. Skip the uninformative default label `agent`.
142
+ */
143
+ export function composeBroadcastFooter(ctx) {
144
+ const agent = ctx.agent?.trim();
145
+ const agentLabel = agent && agent !== 'agent' ? agent : undefined;
146
+ const session = shortSessionChunk(ctx.session);
147
+ const host = shortHost(ctx.host);
148
+ let who;
149
+ if (agentLabel && session)
150
+ who = `${agentLabel}/${session}`;
151
+ else if (agentLabel)
152
+ who = agentLabel;
153
+ else if (session)
154
+ who = session;
155
+ if (who && host)
156
+ return `Sent from ${who} on ${host}`;
157
+ if (who)
158
+ return `Sent from ${who}`;
159
+ if (host)
160
+ return `Sent from host ${host}`;
161
+ return undefined;
162
+ }
163
+ /**
164
+ * Human-facing body for a messaging sink (`{message}`).
165
+ *
166
+ * ```
167
+ * Title in a few words
168
+ *
169
+ * Body of what happened or the ask.
170
+ *
171
+ * Sent from grok/a02da0e2 on mac-mini
172
+ * agents focus a02da0e2 (blocks only)
173
+ * https://… (optional attach URL)
174
+ * ```
175
+ *
176
+ * Title first (scannable subject). Blank line. Body. Footer provenance so a
177
+ * fleet of agents is attributable without crowding the ask. Prefer `{message}`
178
+ * over bare `{text}` in messaging sinks.
99
179
  */
100
180
  export function composeBroadcastMessage(ctx) {
101
- const head = ctx.project ? `${ctx.project} · ${ctx.text}` : ctx.text;
181
+ const title = scrubOutboundDashes(ctx.title ?? '');
182
+ const body = scrubOutboundDashes(ctx.text ?? '');
183
+ // Title preferred; if an older post has no title, body alone still sends.
184
+ const head = title || body;
185
+ const mid = title && body && title !== body ? body : undefined;
186
+ const footer = composeBroadcastFooter(ctx);
102
187
  const link = ctx.links?.find((l) => /^https?:\/\//i.test(l));
103
- // A block's second line is the command that unblocks it. The operator reading
104
- // this on a phone should not have to go find the session the one action they
105
- // must take travels with the ask. A status post has no such action, so it keeps
106
- // the link there instead.
107
- const tail = [ctx.focus, link].filter(Boolean);
108
- return tail.length ? `${head}\n${tail.join('\n')}` : head;
188
+ // Block focus and link trail after the "Sent from" footer so the human
189
+ // sentence stays at the top and the action/link are still one glance away.
190
+ const trail = [footer, ctx.focus, link].filter(Boolean);
191
+ const parts = [];
192
+ if (head)
193
+ parts.push(head);
194
+ if (mid) {
195
+ // Blank line between subject and body (title, then space, then message).
196
+ parts.push('');
197
+ parts.push(mid);
198
+ }
199
+ if (trail.length) {
200
+ // Blank line before the footer block (iPhone "Sent from my iPhone" spacing).
201
+ if (parts.length)
202
+ parts.push('');
203
+ parts.push(trail.join('\n'));
204
+ }
205
+ return parts.join('\n').trim();
109
206
  }
110
207
  /** The values a template may reference, resolved once per post. */
111
208
  function templateVars(ctx) {
112
209
  return {
210
+ title: ctx.title,
113
211
  text: ctx.text,
114
212
  ticket: ctx.ticket,
115
213
  project: ctx.project,
@@ -2,8 +2,15 @@ import { type ActivityEvent, type Attachment } from './activity.js';
2
2
  import { type PidSessionEntry } from './session/pid-registry.js';
3
3
  /** Soft cap so a runaway agent can't flood the activity lane with essays. */
4
4
  export declare const STATUS_POST_MAX_CHARS = 500;
5
+ /** Title is a phone subject line - about four or five words, not a paragraph. */
6
+ export declare const STATUS_TITLE_MAX_CHARS = 60;
5
7
  export interface FeedPostInput {
6
- /** Human-readable progress text (required). Domain-agnostic free text. */
8
+ /**
9
+ * Short subject (required for new posts). ~4–5 words. Phone broadcasts put
10
+ * this on the first line so a scan names the topic before the body.
11
+ */
12
+ title: string;
13
+ /** Body text (required). Domain-agnostic free text — what happened / the ask. */
7
14
  text: string;
8
15
  /** Override session id (escape hatch for scripts/tests). Prefer auto-resolve. */
9
16
  sessionId?: string;
@@ -92,9 +99,16 @@ export declare function buildAttachments(tokens: string[] | undefined, ctx: {
92
99
  sessionId: string;
93
100
  updateId: string;
94
101
  }): Attachment[];
102
+ /**
103
+ * Collapse whitespace and strip em/en dashes (house rule: no em-dashes in
104
+ * agent-authored outbound copy — phones and plain text render them poorly).
105
+ */
106
+ export declare function scrubDashes(text: string): string;
95
107
  export declare function normalizeStatusText(text: string): string;
108
+ /** Normalize a short subject line for a post. */
109
+ export declare function normalizeStatusTitle(title: string): string;
96
110
  /**
97
111
  * Append a `status.posted` milestone for the calling agent.
98
- * Throws if text is empty or session identity cannot be resolved.
112
+ * Throws if title/text is empty or session identity cannot be resolved.
99
113
  */
100
114
  export declare function postFeedStatus(input: FeedPostInput): FeedPostResult;
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Agent status posts — deliberate progress messages into the activity stream.
3
3
  *
4
- * Surface: `agents feed post <text>` (agent-callable; humans watch via
5
- * `agents feed` / `agents activity` / `agents events --module activity`).
4
+ * Surface: `agents feed post --title <subject> <body>` (agent-callable; humans
5
+ * watch via `agents feed` / `agents activity` / `agents events --module activity`).
6
6
  *
7
7
  * Identity is automatic: session id, agent, cwd, launch/pid/tmux provenance
8
8
  * are resolved from the process environment and the per-pid launch registry
9
- * (`lib/session/pid-registry.ts`). The agent only authors free-form text
10
- * no domain-specific flags (tickets, URLs, tracks).
9
+ * (`lib/session/pid-registry.ts`). The agent authors a short title + body
10
+ * no domain-specific flags (tickets, URLs, tracks). Phone `{message}` ends with
11
+ * a "Sent from agent/session on host" footer.
11
12
  *
12
13
  * Storage: append-only activity log as a `status.posted` milestone. Does NOT
13
14
  * open a feed block (blocks remain "needs you" state only).
@@ -23,6 +24,8 @@ import { isValidMailboxId } from './mailbox.js';
23
24
  import { listPidSessionEntries, readPidSessionEntry, } from './session/pid-registry.js';
24
25
  /** Soft cap so a runaway agent can't flood the activity lane with essays. */
25
26
  export const STATUS_POST_MAX_CHARS = 500;
27
+ /** Title is a phone subject line - about four or five words, not a paragraph. */
28
+ export const STATUS_TITLE_MAX_CHARS = 60;
26
29
  /**
27
30
  * Resolve who is posting. Order:
28
31
  * 1. Explicit --session flag
@@ -241,22 +244,46 @@ export function buildAttachments(tokens, ctx) {
241
244
  }
242
245
  return out;
243
246
  }
247
+ /**
248
+ * Collapse whitespace and strip em/en dashes (house rule: no em-dashes in
249
+ * agent-authored outbound copy — phones and plain text render them poorly).
250
+ */
251
+ export function scrubDashes(text) {
252
+ return text
253
+ .replace(/\u2014/g, ' - ') // em dash —
254
+ .replace(/\u2013/g, ' - ') // en dash –
255
+ .replace(/\s+/g, ' ')
256
+ .trim();
257
+ }
244
258
  export function normalizeStatusText(text) {
245
- const collapsed = text.replace(/\s+/g, ' ').trim();
259
+ const collapsed = scrubDashes(text);
246
260
  if (!collapsed)
247
261
  return '';
248
262
  if (collapsed.length <= STATUS_POST_MAX_CHARS)
249
263
  return collapsed;
250
264
  return `${collapsed.slice(0, STATUS_POST_MAX_CHARS - 1)}…`;
251
265
  }
266
+ /** Normalize a short subject line for a post. */
267
+ export function normalizeStatusTitle(title) {
268
+ const collapsed = scrubDashes(title);
269
+ if (!collapsed)
270
+ return '';
271
+ if (collapsed.length <= STATUS_TITLE_MAX_CHARS)
272
+ return collapsed;
273
+ return `${collapsed.slice(0, STATUS_TITLE_MAX_CHARS - 1)}…`;
274
+ }
252
275
  /**
253
276
  * Append a `status.posted` milestone for the calling agent.
254
- * Throws if text is empty or session identity cannot be resolved.
277
+ * Throws if title/text is empty or session identity cannot be resolved.
255
278
  */
256
279
  export function postFeedStatus(input) {
280
+ const title = normalizeStatusTitle(input.title ?? '');
257
281
  const detail = normalizeStatusText(input.text);
282
+ if (!title) {
283
+ throw new Error('Title is empty. Usage: agents feed post --title "Short subject" "what just happened"');
284
+ }
258
285
  if (!detail) {
259
- throw new Error('Status text is empty. Usage: agents feed post "what just happened"');
286
+ throw new Error('Status text is empty. Usage: agents feed post --title "Short subject" "what just happened"');
260
287
  }
261
288
  const identity = resolvePostIdentity(input);
262
289
  if (!identity) {
@@ -285,6 +312,7 @@ export function postFeedStatus(input) {
285
312
  cwd: identity.cwd,
286
313
  agent: identity.agent,
287
314
  tool: 'feed.post',
315
+ title,
288
316
  detail,
289
317
  ...(project ? { project } : {}),
290
318
  ...(identity.pid !== undefined ? { pid: identity.pid } : {}),
package/dist/lib/feed.js CHANGED
@@ -255,7 +255,7 @@ export function clearBlockLifecycle(blockId, root) {
255
255
  export function buildDeclaredBlock(agent, input) {
256
256
  const text = input.text.trim().replace(/\s+/g, ' ');
257
257
  if (!text) {
258
- throw new Error('Block text is empty. Usage: agents feed post "what you need from the user" --blocked');
258
+ throw new Error('Block text is empty. Usage: agents feed post --title "Short subject" "what you need from the user" --blocked');
259
259
  }
260
260
  const options = (input.options ?? [])
261
261
  .map((label) => label.trim())
@@ -198,6 +198,19 @@ export declare function installHooksCentrally(source: string): Promise<{
198
198
  * User dir takes priority; deduplication preserves first occurrence.
199
199
  */
200
200
  export declare function listCentralHooks(): HookEntry[];
201
+ /**
202
+ * Normalize a hook `timeout` from agents.yaml into a whole number of seconds.
203
+ *
204
+ * A bare number stays seconds (`timeout: 30` → 30) for backward compatibility.
205
+ * A Go-style duration string is parsed into seconds: `5s`, `2m`, `1h30m`,
206
+ * `90s`, `1h`. This intentionally does NOT reuse {@link parseTimeout} from
207
+ * routines.ts — that one returns milliseconds, has no seconds (`s`) unit, and
208
+ * floors at one minute, none of which fit hook timeouts (typically 5–600s).
209
+ *
210
+ * Returns the seconds value, or `null` when the input is not a positive number
211
+ * or a parseable duration string — the caller decides how to surface that.
212
+ */
213
+ export declare function normalizeHookTimeoutSeconds(value: unknown): number | null;
201
214
  /**
202
215
  * Parse hook manifests. Reads system hooks from ~/.agents/.system/hooks.yaml
203
216
  * (npm-shipped defaults) and user hooks from the `hooks:` section of
@@ -205,7 +218,9 @@ export declare function listCentralHooks(): HookEntry[];
205
218
  * A user entry with `enabled: false` disables the system-shipped hook of
206
219
  * the same name without forking the system file.
207
220
  *
208
- * Hooks marked `enabled: false` are dropped from the returned map.
221
+ * Hooks marked `enabled: false` are dropped from the returned map. A hook
222
+ * `timeout` written as a duration string (`5s`, `2m`) is normalized to a
223
+ * seconds number here, so every downstream serializer keeps reading a number.
209
224
  */
210
225
  export declare function parseHookManifest(opts?: {
211
226
  warn?: boolean;
package/dist/lib/hooks.js CHANGED
@@ -952,6 +952,44 @@ export function listCentralHooks() {
952
952
  }
953
953
  return results;
954
954
  }
955
+ /**
956
+ * Normalize a hook `timeout` from agents.yaml into a whole number of seconds.
957
+ *
958
+ * A bare number stays seconds (`timeout: 30` → 30) for backward compatibility.
959
+ * A Go-style duration string is parsed into seconds: `5s`, `2m`, `1h30m`,
960
+ * `90s`, `1h`. This intentionally does NOT reuse {@link parseTimeout} from
961
+ * routines.ts — that one returns milliseconds, has no seconds (`s`) unit, and
962
+ * floors at one minute, none of which fit hook timeouts (typically 5–600s).
963
+ *
964
+ * Returns the seconds value, or `null` when the input is not a positive number
965
+ * or a parseable duration string — the caller decides how to surface that.
966
+ */
967
+ export function normalizeHookTimeoutSeconds(value) {
968
+ if (typeof value === 'number') {
969
+ return Number.isFinite(value) && value > 0 ? value : null;
970
+ }
971
+ if (typeof value === 'string') {
972
+ const s = value.trim();
973
+ if (s === '')
974
+ return null;
975
+ // A bare integer string means seconds, matching the bare-number form.
976
+ if (/^\d+$/.test(s)) {
977
+ const n = Number(s);
978
+ return n > 0 ? n : null;
979
+ }
980
+ const m = s.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i);
981
+ if (!m)
982
+ return null;
983
+ const weeks = Number(m[1] || 0);
984
+ const days = Number(m[2] || 0);
985
+ const hours = Number(m[3] || 0);
986
+ const minutes = Number(m[4] || 0);
987
+ const seconds = Number(m[5] || 0);
988
+ const total = ((weeks * 7 + days) * 24 + hours) * 3600 + minutes * 60 + seconds;
989
+ return total > 0 ? total : null;
990
+ }
991
+ return null;
992
+ }
955
993
  /**
956
994
  * Parse hook manifests. Reads system hooks from ~/.agents/.system/hooks.yaml
957
995
  * (npm-shipped defaults) and user hooks from the `hooks:` section of
@@ -959,7 +997,9 @@ export function listCentralHooks() {
959
997
  * A user entry with `enabled: false` disables the system-shipped hook of
960
998
  * the same name without forking the system file.
961
999
  *
962
- * Hooks marked `enabled: false` are dropped from the returned map.
1000
+ * Hooks marked `enabled: false` are dropped from the returned map. A hook
1001
+ * `timeout` written as a duration string (`5s`, `2m`) is normalized to a
1002
+ * seconds number here, so every downstream serializer keeps reading a number.
963
1003
  */
964
1004
  export function parseHookManifest(opts = {}) {
965
1005
  const warn = opts.warn !== false;
@@ -1026,6 +1066,26 @@ export function parseHookManifest(opts = {}) {
1026
1066
  if (def.enabled === false)
1027
1067
  delete merged[name];
1028
1068
  }
1069
+ // Normalize each surviving hook's timeout to a seconds number, so the raw
1070
+ // agents.yaml can express it as a duration string (`5s`, `2m`) while every
1071
+ // downstream serializer keeps consuming a plain number. An unparseable value
1072
+ // is dropped with a warning rather than silently coerced to a wrong duration.
1073
+ for (const [name, def] of Object.entries(merged)) {
1074
+ const raw = def.timeout;
1075
+ if (raw === undefined)
1076
+ continue;
1077
+ const seconds = normalizeHookTimeoutSeconds(raw);
1078
+ if (seconds === null) {
1079
+ if (warn) {
1080
+ console.warn(`[agents hooks] Hook '${name}' has an invalid timeout ${JSON.stringify(raw)}; ` +
1081
+ `expected seconds or a duration string like '5s', '2m', '1h30m'. Ignoring it.`);
1082
+ }
1083
+ delete def.timeout;
1084
+ }
1085
+ else {
1086
+ def.timeout = seconds;
1087
+ }
1088
+ }
1029
1089
  return merged;
1030
1090
  }
1031
1091
  export function selectHookManifest(manifest, selected) {
@@ -80,7 +80,7 @@ export interface ActionConfig {
80
80
  timeout?: string;
81
81
  /** routine: name of an existing routine to fire. */
82
82
  routine?: string;
83
- /** notify: channel for the notification (default `telegram`). */
83
+ /** notify: override the owner channel (defaults to `notify.owner.channel`). */
84
84
  notifyChannel?: string;
85
85
  /** webhook-out: URL to POST the event to. */
86
86
  url?: string;
@@ -5,8 +5,12 @@
5
5
  * action goes through the *same* detached spawn cron and webhook fires use
6
6
  * (executeJobDetached, lib/runner.ts) — a monitor never duplicates spawn logic,
7
7
  * it synthesizes a JobConfig and hands it to the one dispatch seam. `notify`
8
- * reuses the openclaw Telegram path (lib/notify.ts); `webhook-out` POSTs the event.
8
+ * routes the owner through the one channel seam (sendToOwner → lookupTransport,
9
+ * lib/notify.ts) — recipient from notify.owner, no hardcoded chat id, and an
10
+ * unresolvable channel comes back as `ok: false` instead of exiting the daemon;
11
+ * `webhook-out` POSTs the event.
9
12
  */
13
+ import type { Meta } from '../types.js';
10
14
  import type { ActionConfig, MonitorConfig, MonitorEvent } from './config.js';
11
15
  /** Outcome of a dispatched action. */
12
16
  export interface DispatchResult {
@@ -25,4 +29,4 @@ export declare function injectEvent(prompt: string, event: MonitorEvent): string
25
29
  * use. `routine` fires an existing routine with the event injected. `notify` and
26
30
  * `webhook-out` are terminal side-effects.
27
31
  */
28
- export declare function dispatchAction(monitor: MonitorConfig, event: MonitorEvent): Promise<DispatchResult>;
32
+ export declare function dispatchAction(monitor: MonitorConfig, event: MonitorEvent, meta?: Meta): Promise<DispatchResult>;
@@ -5,14 +5,14 @@
5
5
  * action goes through the *same* detached spawn cron and webhook fires use
6
6
  * (executeJobDetached, lib/runner.ts) — a monitor never duplicates spawn logic,
7
7
  * it synthesizes a JobConfig and hands it to the one dispatch seam. `notify`
8
- * reuses the openclaw Telegram path (lib/notify.ts); `webhook-out` POSTs the event.
8
+ * routes the owner through the one channel seam (sendToOwner → lookupTransport,
9
+ * lib/notify.ts) — recipient from notify.owner, no hardcoded chat id, and an
10
+ * unresolvable channel comes back as `ok: false` instead of exiting the daemon;
11
+ * `webhook-out` POSTs the event.
9
12
  */
10
- import { execFile } from 'child_process';
11
- import { promisify } from 'util';
12
13
  import { executeJobDetached } from '../runner.js';
13
14
  import { readJob } from '../routines.js';
14
- import { buildOpenClawNotifyArgs } from '../notify.js';
15
- const execFileAsync = promisify(execFile);
15
+ import { sendToOwner } from '../notify.js';
16
16
  /** Replace `{event}` in a prompt with the fired event summary. */
17
17
  export function injectEvent(prompt, event) {
18
18
  return prompt.replace(/\{event\}/g, event.summary);
@@ -24,7 +24,7 @@ export function injectEvent(prompt, event) {
24
24
  * use. `routine` fires an existing routine with the event injected. `notify` and
25
25
  * `webhook-out` are terminal side-effects.
26
26
  */
27
- export async function dispatchAction(monitor, event) {
27
+ export async function dispatchAction(monitor, event, meta) {
28
28
  const action = monitor.action;
29
29
  if (action.type === 'run') {
30
30
  const job = {
@@ -63,16 +63,11 @@ export async function dispatchAction(monitor, event) {
63
63
  }
64
64
  }
65
65
  if (action.type === 'notify') {
66
- const args = buildOpenClawNotifyArgs(event.summary, {
67
- channel: action.notifyChannel ?? 'telegram',
66
+ const result = await sendToOwner(event.summary, {
67
+ ...(meta ? { meta } : {}),
68
+ ...(action.notifyChannel ? { channel: action.notifyChannel } : {}),
68
69
  });
69
- try {
70
- await execFileAsync('openclaw', args);
71
- return { kind: 'notify', ok: true };
72
- }
73
- catch (err) {
74
- return { kind: 'notify', ok: false, error: err.message };
75
- }
70
+ return { kind: 'notify', ok: result.ok, ...(result.ok ? {} : { error: result.error }) };
76
71
  }
77
72
  // webhook-out
78
73
  if (!action.url)
@@ -1,9 +1,29 @@
1
+ /**
2
+ * Owner notifier — the one seam for "ping the human" messages.
3
+ *
4
+ * Every human-facing owner notification (feed urgent-block dispatch, monitor
5
+ * `notify` action, `agents notify`) funnels through the single channel seam:
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
8
+ * owner is honoured by every path at once. `notify.transports` picks the actual
9
+ * provider per host (rush telegram on zion, openclaw-telegram on mac-mini).
10
+ * Best-effort: a delivery failure is returned to the caller, never thrown, so a
11
+ * notification hiccup never blocks the agent. That is why this module resolves
12
+ * with `lookupTransport` and not the `die()`-capable `resolveTransport` — the
13
+ * monitor daemon and the feed-dispatch loop call in here, and `process.exit()`
14
+ * would take them down on a typo'd channel name, bypassing their try/catch.
15
+ */
1
16
  import type { OpenBlock } from './feed.js';
2
- export interface NotifyOptions {
17
+ import type { Meta } from './types.js';
18
+ import type { SendResult } from './channels/registry.js';
19
+ export interface OwnerNotifyOptions {
20
+ /** Config source (defaults to `readMeta()`); lets callers/tests inject it. */
21
+ meta?: Meta;
22
+ /** Override the owner channel from `notify.owner.channel`. */
3
23
  channel?: string;
4
- account?: string;
5
- /** OpenClaw destination (Telegram chat id). Defaults to Muqsit's chat. */
24
+ /** Override the owner target from `notify.owner.to`. */
6
25
  target?: string;
26
+ /** Resolve + build the delivery but do not actually send. */
7
27
  dryRun?: boolean;
8
28
  }
9
29
  export interface NotifyResult {
@@ -12,6 +32,22 @@ export interface NotifyResult {
12
32
  error?: string;
13
33
  }
14
34
  export declare function formatUrgentBlockMessage(block: OpenBlock): string;
15
- /** Build openclaw argv for urgent notify (exported for tests). */
16
- export declare function buildOpenClawNotifyArgs(text: string, options?: Pick<NotifyOptions, 'channel' | 'account' | 'target'>): string[];
17
- export declare function notifyUrgentBlock(block: OpenBlock, options?: NotifyOptions): Promise<NotifyResult>;
35
+ /**
36
+ * Build openclaw argv for a Telegram send (used by the openclaw-telegram
37
+ * provider and its tests). `target` is required — the recipient is always
38
+ * resolved by the caller, never defaulted to a hardcoded number here.
39
+ */
40
+ export declare function buildOpenClawNotifyArgs(text: string, opts: {
41
+ target: string;
42
+ channel?: string;
43
+ account?: string;
44
+ }): string[];
45
+ /**
46
+ * Deliver a message to the configured owner through the one channel seam.
47
+ * `channel`/`target` default to `notify.owner.{channel,to}`; `notify.transports`
48
+ * selects the provider per host. A missing owner config or a delivery failure
49
+ * (e.g. openclaw not on PATH) returns a clean `SendResult` error — never a raw
50
+ * ENOENT — so callers surface a consistent, best-effort failure.
51
+ */
52
+ export declare function sendToOwner(text: string, options?: OwnerNotifyOptions): Promise<SendResult>;
53
+ export declare function notifyUrgentBlock(block: OpenBlock, options?: OwnerNotifyOptions): Promise<NotifyResult>;
@@ -1,14 +1,6 @@
1
- /**
2
- * Urgent-block phone notifier.
3
- *
4
- * Reuses the OpenClaw Telegram gateway on the local mac-mini (Jeff/`default` bot)
5
- * instead of raw bot tokens. Notifies once per block (tracked by `notifiedAt`).
6
- * Best-effort: any openclaw failure is surfaced as a warning, not a hard error,
7
- * so a notification hiccup never blocks the agent.
8
- */
9
- import { execFile } from 'child_process';
10
- import { promisify } from 'util';
11
- const execFileAsync = promisify(execFile);
1
+ import { readMeta } from './state.js';
2
+ import { registerBuiltinProviders } from './channels/providers/index.js';
3
+ import { lookupTransport } from './channels/resolve.js';
12
4
  export function formatUrgentBlockMessage(block) {
13
5
  const q = block.questions[0];
14
6
  const header = q?.header ? `[${q.header}] ` : '';
@@ -18,11 +10,14 @@ export function formatUrgentBlockMessage(block) {
18
10
  const cost = block.costOfDelay ?? 'low';
19
11
  return `URGENT ${cls.toUpperCase()}${host}: ${header}${text} (cost: ${cost}, id: ${block.blockId})`;
20
12
  }
21
- /** Build openclaw argv for urgent notify (exported for tests). */
22
- export function buildOpenClawNotifyArgs(text, options = {}) {
23
- const channel = options.channel ?? 'telegram';
24
- const account = options.account ?? 'default';
25
- const target = options.target ?? '6078999250';
13
+ /**
14
+ * Build openclaw argv for a Telegram send (used by the openclaw-telegram
15
+ * provider and its tests). `target` is required — the recipient is always
16
+ * resolved by the caller, never defaulted to a hardcoded number here.
17
+ */
18
+ export function buildOpenClawNotifyArgs(text, opts) {
19
+ const channel = opts.channel ?? 'telegram';
20
+ const account = opts.account ?? 'default';
26
21
  return [
27
22
  'message',
28
23
  'send',
@@ -31,11 +26,38 @@ export function buildOpenClawNotifyArgs(text, options = {}) {
31
26
  '--account',
32
27
  account,
33
28
  '--target',
34
- target,
29
+ opts.target,
35
30
  '--message',
36
31
  text,
37
32
  ];
38
33
  }
34
+ /**
35
+ * Deliver a message to the configured owner through the one channel seam.
36
+ * `channel`/`target` default to `notify.owner.{channel,to}`; `notify.transports`
37
+ * selects the provider per host. A missing owner config or a delivery failure
38
+ * (e.g. openclaw not on PATH) returns a clean `SendResult` error — never a raw
39
+ * ENOENT — so callers surface a consistent, best-effort failure.
40
+ */
41
+ export async function sendToOwner(text, options = {}) {
42
+ const meta = options.meta ?? readMeta();
43
+ const owner = meta.notify?.owner;
44
+ const channel = options.channel ?? owner?.channel;
45
+ const target = options.target ?? owner?.to;
46
+ if (!channel || !target) {
47
+ return {
48
+ ok: false,
49
+ channel: channel ?? 'unknown',
50
+ id: target ?? '',
51
+ error: 'notify.owner.{channel,to} not set in agents.yaml',
52
+ };
53
+ }
54
+ registerBuiltinProviders();
55
+ const { provider, error } = lookupTransport(channel, meta);
56
+ if (!provider) {
57
+ return { ok: false, channel, id: target, error };
58
+ }
59
+ return provider.send(text, { target, dryRun: options.dryRun });
60
+ }
39
61
  export async function notifyUrgentBlock(block, options = {}) {
40
62
  if (block.notifiedAt) {
41
63
  return { ok: true, skipped: true };
@@ -43,19 +65,6 @@ export async function notifyUrgentBlock(block, options = {}) {
43
65
  if (options.dryRun) {
44
66
  return { ok: true, skipped: true };
45
67
  }
46
- // Check that openclaw is installed.
47
- try {
48
- await execFileAsync('which', ['openclaw']);
49
- }
50
- catch {
51
- return { ok: false, error: 'openclaw CLI not found on PATH' };
52
- }
53
- const text = formatUrgentBlockMessage(block);
54
- try {
55
- await execFileAsync('openclaw', buildOpenClawNotifyArgs(text, options));
56
- return { ok: true };
57
- }
58
- catch (err) {
59
- return { ok: false, error: err.message };
60
- }
68
+ const result = await sendToOwner(formatUrgentBlockMessage(block), options);
69
+ return result.ok ? { ok: true } : { ok: false, error: result.error };
61
70
  }
@@ -284,6 +284,12 @@ export type HookCache = string | HookCacheConfig;
284
284
  export interface ManifestHook {
285
285
  script: string;
286
286
  events: string[];
287
+ /**
288
+ * Seconds before the hook is killed (default 600). In agents.yaml this may be
289
+ * written as a bare number (seconds) or a duration string (`5s`, `2m`,
290
+ * `1h30m`); `parseHookManifest` normalizes it to a seconds number here, so
291
+ * consumers always see a number.
292
+ */
287
293
  timeout?: number;
288
294
  matcher?: string;
289
295
  /** @deprecated Use the agent capability table; field is ignored. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.21.0",
3
+ "version": "1.21.1",
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",