@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 +26 -0
- package/README.md +1 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/feed.js +37 -23
- package/dist/commands/monitors.js +8 -6
- package/dist/lib/activity.d.ts +6 -0
- package/dist/lib/activity.js +3 -0
- package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
- package/dist/lib/channels/resolve.d.ts +18 -1
- package/dist/lib/channels/resolve.js +15 -5
- package/dist/lib/feed-broadcast.d.ts +41 -5
- package/dist/lib/feed-broadcast.js +110 -12
- package/dist/lib/feed-post.d.ts +16 -2
- package/dist/lib/feed-post.js +35 -7
- package/dist/lib/feed.js +1 -1
- package/dist/lib/hooks.d.ts +16 -1
- package/dist/lib/hooks.js +61 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/monitors/config.d.ts +1 -1
- package/dist/lib/monitors/dispatch.d.ts +6 -2
- package/dist/lib/monitors/dispatch.js +10 -15
- package/dist/lib/notify.d.ts +42 -6
- package/dist/lib/notify.js +41 -32
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/types.d.ts +6 -0
- package/package.json +1 -1
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 "
|
|
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
|
package/dist/commands/feed.js
CHANGED
|
@@ -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...>', '
|
|
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
|
|
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
|
-
#
|
|
248
|
-
|
|
249
|
-
agents feed post "
|
|
250
|
-
agents feed post "ready
|
|
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
|
|
253
|
-
agents feed post "
|
|
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
|
-
#
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
agents feed post "
|
|
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 "
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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 ?? '
|
|
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 (
|
|
199
|
-
|
|
200
|
-
|
|
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]', '
|
|
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'
|
|
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}`));
|
package/dist/lib/activity.d.ts
CHANGED
|
@@ -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). */
|
package/dist/lib/activity.js
CHANGED
|
@@ -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, {
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
7
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
91
|
-
*
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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,
|
package/dist/lib/feed-post.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/lib/feed-post.js
CHANGED
|
@@ -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 <
|
|
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
|
|
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
|
|
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())
|
package/dist/lib/hooks.d.ts
CHANGED
|
@@ -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) {
|
|
Binary file
|
|
Binary file
|
|
@@ -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:
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
67
|
-
|
|
66
|
+
const result = await sendToOwner(event.summary, {
|
|
67
|
+
...(meta ? { meta } : {}),
|
|
68
|
+
...(action.notifyChannel ? { channel: action.notifyChannel } : {}),
|
|
68
69
|
});
|
|
69
|
-
|
|
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)
|
package/dist/lib/notify.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
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>;
|
package/dist/lib/notify.js
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
}
|
|
Binary file
|
|
Binary file
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -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.
|
|
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",
|