@phnx-labs/agents-cli 1.22.61 → 1.22.63

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.63
4
+
5
+ - **Managed share endpoint enforces a per-user storage quota, object limit, per-file size cap, and publish rate limit (PHNX-3542).** The managed `share.agents-cli.sh` Worker authenticated any Phoenix ID bearer and then accepted **unbounded** writes into shared R2 — no quota, no rate limit, no size cap — which blocked opening publishing to third parties. Each managed (Phoenix-identity) publish now charges a per-user usage ledger stored in R2 at `__usage/<owner>` (a conditional-put CAS object, mirroring the existing `__views`/`__handles` precedent — no Durable Object, no new binding): free tier is 200 MiB total, 150 canonical pages, 20 MiB per file, and 60 publishes/hour. Enforcement measures the **real request body** (bounded-buffered so a streaming body can't exceed the cap) and rejects on the true size **before any write**, so a spoofed-low declared size can't bypass the caps or destroy an existing page. It **fails loud** — `413` for a file, object-count, or byte-quota overage, `429` (with `Retry-After`) for the rate limit — and refunds bytes + object count on delete and on lazy expiry. Covers/views are server-generated overhead and excluded from the quota. BYO (`WRITE_TOKEN`) publishes write to the operator's own bucket at their own cost and are **unaffected** (a deliberate, documented policy). A `SHARE_PLANS` map is the seam for future paid tiers (billing follow-up PHNX-3569). Source: `cli/src/lib/share/worker-template.ts`.
6
+
7
+ - Let feed channel sinks customize their delivered body with existing post placeholders, including fail-closed `{ticket}` routing for clickable tracker links in team channels.
8
+
9
+ ## 1.22.62
10
+
11
+ - **Owner notifications fan out across the configured normal-severity channels (PHNX-3567).** `agents send --to owner`, deprecated `agents notify`, monitor notifications, and an important feed's owner sink now attempt every addressable entry named by `owner.policy.normal` in `humans.yaml`, instead of silently selecting only the first. Each Rush-backed destination that cannot deliver on a Linux worker forwards its explicit channel and target to a capable Mac, avoiding both shell quoting and policy re-expansion/duplicate sends. Partial failures stay visible while successful channels still deliver; legacy single-channel configs retain their old behavior. Source: `cli/src/lib/humans.ts`, `cli/src/lib/notify.ts`, `cli/src/lib/channels/owner-forward.ts`, `cli/src/lib/feed-broadcast.ts`.
12
+
3
13
  ## 1.22.61
4
14
 
5
15
  - **`agents harness` wizard: model catalog, connection test, and edit matrix (PHNX-2218/2220/2221/2222).** The create/edit wizard now picks the model from the host's own catalog (`getModelCatalog`) with a free-text escape hatch, gates the endpoint step to hosts that actually carry one, and runs a real pre-save connection test — `agents run <name> "say alive in one word" --headless --timeout 60s` classified into pass / auth / endpoint / model — behind a confirm with `--test`/`--no-test`, offering keep / edit / delete on failure rather than saving a broken harness silently. A resolver-sourced `harnessEditable` matrix disables (with a reason) any param the host's API format can't carry. Source: `apps/cli/src/commands/harness.ts`, `apps/cli/src/commands/harness-wizard.ts`, `apps/cli/src/commands/harness-hooks.ts`, `apps/cli/src/lib/harness-connection-test.ts`.
package/README.md CHANGED
@@ -461,6 +461,12 @@ agents feed answer <key> --choice 0 # first answer wins; route over the recorded
461
461
  agents feed post --title "Halfway done" "CI green, watching merge" # title + body
462
462
  ```
463
463
 
464
+ Important posts can reach every owner channel named in
465
+ `~/.agents/humans.yaml` under `owner.policy.normal` (for example iMessage and
466
+ Slack). Owner delivery attempts every selected channel, reports partial
467
+ failures, and safely forwards Rush-backed channels from headless workers to a
468
+ capable Mac; ordinary milestone posts remain record-only unless configured.
469
+
464
470
  Top-level questions and waiting notifications publish one atomic open-block record per session, including the mailbox id, host, runtime, and every answer option. The default view collapses agents under the **outcome** they serve (Linear ticket, PR, worktree slug, or Unassigned) so a 1,100-agent fleet reads as dozens of deliverables. Answered, resumed, and stopped blocks clear automatically; Task subagents are excluded. The rendered reply command uses the same mailbox id with `agents message`, so the decision routes back to the agent that asked it.
465
471
 
466
472
  ### Auto-nudge stalls
@@ -20,7 +20,7 @@ import { type OpenBlock } from '../lib/feed/feed.js';
20
20
  import { type OutcomeGroup, type SessionOutcomeHint } from '../lib/feed-outcome.js';
21
21
  import { filterBlocksForFeed } from '../lib/ask-classifier.js';
22
22
  import { type FeedSessionSignal } from '../lib/feed-ranking.js';
23
- export declare const FEED_POST_HELP = "\nExamples:\n # Title (subject) + body. Phone broadcasts put title first, body after a\n # blank line, then a \"Sent from agent/session on host\" footer.\n agents feed post --title \"CHANGELOG pushed\" \"Watching CI and mac-mini E2E\"\n agents feed post --title \"Cover ready\" \"render at ./out/cover.png\" --attach ./out/cover.png\n agents feed post --title \"Ready for review\" \"PR opened, waiting on prix-cloud\" --json\n\n # Worth interrupting someone over - reaches sinks gated on minLevel: important:\n agents feed post --title \"npm token expired\" \"Cannot publish the release\" --level important\n\n # Also raise a local desktop banner on THIS machine (same notifier as run\n # --notify), on top of any configured broadcast - useful when you are at the box:\n agents feed post --title \"Build green\" \"all checks passed\" --notify\n\n # Stuck: opens a needs-you block and always broadcasts at important:\n agents feed post --title \"Force-push denied\" \"git-guard blocked PR #1749\" --blocked\n agents feed post --title \"Publish or wait?\" \"npm publish now or after review\" --blocked --option publish --option wait\n agents feed post --title \"Delete preview env?\" \"stale preview still running\" --blocked --default \"leave it\"\n\n # Exhaust self-serve FIRST. A block is for what you genuinely cannot do:\n # a credential only the user holds, a decision only they can make, an\n # approval only they can give. Not \"should I do the obvious next step?\".\n\n # Outside a run, pass the session explicitly:\n agents feed post --title \"Manual note\" \"context for the next agent\" --session 00998b0e-2d15-4d2f-a58b-974a886c9b47\n\nIdentity (session, agent, host, runtime, pid, launchId) is stamped automatically\nand rides the phone footer of feed.broadcast {message}. Domain facts (tickets,\nPRs) are not CLI flags - the ticket is joined from the session index at post\ntime. No em-dashes in title/body - they are scrubbed on the way out.\n\nConfigure where a post is mirrored under feed.broadcast in agents.yaml - see\ndocs/observability.md. A milestone is always recorded, but it does not text\nthe owner when the sink has minLevel: important. Add --level important for a\nphone-worthy successful update. Use --blocked only when work cannot continue.\nThe owner destination comes from humans.yaml; do not duplicate it in agents.yaml.\n";
23
+ export declare const FEED_POST_HELP = "\nExamples:\n # Title (subject) + body. Phone broadcasts put title first, body after a\n # blank line, then a \"Sent from agent/session on host\" footer.\n agents feed post --title \"CHANGELOG pushed\" \"Watching CI and mac-mini E2E\"\n agents feed post --title \"Cover ready\" \"render at ./out/cover.png\" --attach ./out/cover.png\n agents feed post --title \"Ready for review\" \"PR opened, waiting on prix-cloud\" --json\n\n # Worth interrupting someone over - reaches sinks gated on minLevel: important:\n agents feed post --title \"npm token expired\" \"Cannot publish the release\" --level important\n\n # Also raise a local desktop banner on THIS machine (same notifier as run\n # --notify), on top of any configured broadcast - useful when you are at the box:\n agents feed post --title \"Build green\" \"all checks passed\" --notify\n\n # Stuck: opens a needs-you block and always broadcasts at important:\n agents feed post --title \"Force-push denied\" \"git-guard blocked PR #1749\" --blocked\n agents feed post --title \"Publish or wait?\" \"npm publish now or after review\" --blocked --option publish --option wait\n agents feed post --title \"Delete preview env?\" \"stale preview still running\" --blocked --default \"leave it\"\n\n # Exhaust self-serve FIRST. A block is for what you genuinely cannot do:\n # a credential only the user holds, a decision only they can make, an\n # approval only they can give. Not \"should I do the obvious next step?\".\n\n # Outside a run, pass the session explicitly:\n agents feed post --title \"Manual note\" \"context for the next agent\" --session 00998b0e-2d15-4d2f-a58b-974a886c9b47\n\nIdentity (session, agent, host, runtime, pid, launchId) is stamped automatically\nand rides the phone footer of feed.broadcast {message}. Domain facts (tickets,\nPRs) are not CLI flags - the ticket is joined from the session index at post\ntime. No em-dashes in title/body - they are scrubbed on the way out.\n\nConfigure where a post is mirrored under feed.broadcast in agents.yaml - see\ndocs/observability.md. A channel sink may set message: with placeholders such\nas {message} and {ticket}; a missing placeholder skips that sink. A milestone is always recorded, but it does not text\nthe owner when the sink has minLevel: important. Add --level important for a\nphone-worthy successful update. Use --blocked only when work cannot continue.\nThe owner destination comes from humans.yaml; do not duplicate it in agents.yaml.\n";
24
24
  export declare const FEED_NO_FANOUT_ENV = "AGENTS_FEED_LOCAL";
25
25
  /** Right-hand masthead summary: `N blocks · M agents`. */
26
26
  export declare function formatFeedMastheadRight(blocks: OpenBlock[]): string;
@@ -57,7 +57,8 @@ PRs) are not CLI flags - the ticket is joined from the session index at post
57
57
  time. No em-dashes in title/body - they are scrubbed on the way out.
58
58
 
59
59
  Configure where a post is mirrored under feed.broadcast in agents.yaml - see
60
- docs/observability.md. A milestone is always recorded, but it does not text
60
+ docs/observability.md. A channel sink may set message: with placeholders such
61
+ as {message} and {ticket}; a missing placeholder skips that sink. A milestone is always recorded, but it does not text
61
62
  the owner when the sink has minLevel: important. Add --level important for a
62
63
  phone-worthy successful update. Use --blocked only when work cannot continue.
63
64
  The owner destination comes from humans.yaml; do not duplicate it in agents.yaml.
@@ -726,7 +727,9 @@ async function broadcastBlock(block, extras, meta, notify = false) {
726
727
  /** One line per sink that ran. Silent when nothing is configured. */
727
728
  function reportBroadcast(outcomes) {
728
729
  for (const o of outcomes) {
729
- if (o.ok)
730
+ if (o.ok && o.error)
731
+ console.error(chalk.yellow(` → ${o.name} partial: ${o.error}`));
732
+ else if (o.ok)
730
733
  console.log(chalk.gray(` → ${o.name}`));
731
734
  else
732
735
  console.error(chalk.yellow(` → ${o.name} failed: ${o.error}`));
@@ -425,8 +425,8 @@ function buildAction(options) {
425
425
  }
426
426
  if (options.notify !== undefined) {
427
427
  // --notify may be a bare flag (notify the owner) or carry a channel that
428
- // overrides notify.owner.channel. Left unset, the send resolves the owner
429
- // channel + target from notify.owner in agents.yaml (one source of truth).
428
+ // selects one channel. Left unset, the send fans out every addressable
429
+ // owner.policy.normal destination from humans.yaml (one source of truth).
430
430
  const channel = typeof options.notify === 'string' ? options.notify : undefined;
431
431
  chosen.push({ type: 'notify', ...(channel ? { notifyChannel: channel } : {}) });
432
432
  }
@@ -620,7 +620,7 @@ export function registerMonitorsCommands(program) {
620
620
  .option('--action-timeout <t>', 'Kill the --run action if it runs longer than this (e.g. 10m)')
621
621
  .option('--postcondition <cmd>', 'Shell command that must exit 0 after a --run/--routine action settles; otherwise the fire records as no-effect, not ok. {event} is replaced with the fired event summary')
622
622
  .option('--routine <name>', 'Fire an existing routine on change')
623
- .option('--notify [channel]', 'Notify the owner (notify.owner); [channel] overrides the owner channel')
623
+ .option('--notify [channel]', 'Notify every normal-policy owner channel; [channel] selects one channel')
624
624
  .option('--webhook-out <url>', 'POST the event to this URL')
625
625
  // PLACEMENT / hygiene
626
626
  .option('--device <name>', 'OWNER (not body placement) — the single machine that evaluates + fires (exactly-once). See docs/concepts.md#placement.')
@@ -44,6 +44,8 @@ async function runSend(positionalText, opts, ownerMode) {
44
44
  const suffix = result.msgId ? chalk.dim(` (${result.msgId})`) : '';
45
45
  const dry = envelope.dryRun ? chalk.dim(' [dry-run]') : '';
46
46
  console.log(chalk.green(`Sent via ${result.channel} → ${result.id}`) + suffix + dry);
47
+ if (result.error)
48
+ console.error(chalk.yellow(`Partial delivery failure: ${result.error}`));
47
49
  }
48
50
  const SHARED_NOTES = `
49
51
  Planes (do not mix them up):
@@ -122,7 +124,8 @@ export function registerSendCommand(program) {
122
124
  notes: `
123
125
  DEPRECATED. notify ≡ send --to owner and still works, but new callers
124
126
  should use "agents feed post" (record + optional broadcast) instead.
125
- Set notify.owner.{channel,to} in agents.yaml once per machine/fleet.
127
+ Set owner.channels + owner.policy.normal in humans.yaml once per fleet.
128
+ Every channel listed in the normal policy receives an owner-addressed send.
126
129
 
127
130
  ${SHARED_NOTES}
128
131
  `,
@@ -14,9 +14,10 @@
14
14
  * This mirrors the SSH reroute `agents message` (decideHostTaskRoute →
15
15
  * runOnPeer) and the sessions fan-out already use for work that lives on another
16
16
  * box: pick a reachable peer from the device registry and run the same `agents`
17
- * verb there. Here the verb is `agents send --to owner`, which resolves the
18
- * peer's own (fleet-synced) owner destination and delivers through its local
19
- * rush — so the owner is addressed once, from the one box that can reach them.
17
+ * verb there. Here the verb is `agents send --channel <channel> --to <target>`.
18
+ * Keeping the destination explicit and delivering through the peer's local rush
19
+ * destination explicit prevents a multi-channel owner policy from expanding a
20
+ * second time on the peer and duplicating already-successful channels.
20
21
  *
21
22
  * Best-effort seam: it never throws and never blocks the post. When no capable
22
23
  * peer is reachable it resolves `undefined` and the caller keeps its original
@@ -63,13 +64,17 @@ export declare function planOwnerForward(channel: string, meta: Meta, devices: D
63
64
  }): OwnerForwardPlan;
64
65
  /**
65
66
  * Deliver `text` to the owner FROM one peer over SSH. Runs the peer's own
66
- * `agents send --to owner --text <text> --json`, which resolves that box's
67
- * fleet-synced owner destination and delivers through its local provider.
67
+ * `agents send --channel <channel> --to <target> --text <text> --json`, which
68
+ * delivers the already-resolved destination through its local provider.
68
69
  * Resolves the parsed `SendResult`, or `undefined` when the peer is
69
70
  * unreachable / not a dialable device / answered with unparseable output —
70
71
  * every one of which means "try the next peer".
71
72
  */
72
- export type PeerOwnerSender = (machine: string, text: string) => Promise<SendResult | undefined>;
73
+ export interface PeerOwnerEnvelope {
74
+ thread?: string;
75
+ from?: string;
76
+ }
77
+ export type PeerOwnerSender = (machine: string, text: string, channel: string, target: string, envelope?: PeerOwnerEnvelope) => Promise<SendResult | undefined>;
73
78
  /**
74
79
  * Try each capable peer in order and return the first successful delivery. A
75
80
  * peer that is unreachable or reports its own delivery failure is skipped and
@@ -79,10 +84,11 @@ export type PeerOwnerSender = (machine: string, text: string) => Promise<SendRes
79
84
  *
80
85
  * The transport (`send`) is injectable so the try-order / first-success / stop
81
86
  * orchestration is testable without a live SSH host; the default runs the real
82
- * `agents send --to owner` over SSH.
87
+ * explicit-destination `agents send` over SSH.
83
88
  */
84
- export declare function forwardOwnerNotifyToPeer(text: string, channel: string, meta: Meta, opts?: {
89
+ export declare function forwardOwnerNotifyToPeer(text: string, channel: string, target: string, meta: Meta, opts?: {
85
90
  self?: string;
86
91
  devices?: DeviceProfile[];
87
92
  send?: PeerOwnerSender;
93
+ envelope?: PeerOwnerEnvelope;
88
94
  }): Promise<SendResult | undefined>;
@@ -51,11 +51,15 @@ export function planOwnerForward(channel, meta, devices, self, opts = {}) {
51
51
  return { candidates: [], skip: 'no-capable-peer' };
52
52
  return { candidates };
53
53
  }
54
- async function sendOnPeer(machine, text) {
54
+ async function sendOnPeer(machine, text, channel, target, envelope = {}) {
55
55
  const peer = await resolvePeerTarget(machine);
56
56
  if (!peer)
57
57
  return undefined;
58
- const args = ['send', '--to', 'owner', '--text', text, '--json'];
58
+ const args = ['send', '--channel', channel, '--to', target, '--text', text, '--json'];
59
+ if (envelope.thread)
60
+ args.push('--thread', envelope.thread);
61
+ if (envelope.from)
62
+ args.push('--from', envelope.from);
59
63
  // Reuse the one injection-tested remote-command builder every `--device`
60
64
  // dispatch uses (posix `bash -lc` / Windows `-EncodedCommand`), rather than a
61
65
  // second hand-rolled quoting path on a security-sensitive seam. The env map is
@@ -83,9 +87,9 @@ async function sendOnPeer(machine, text) {
83
87
  *
84
88
  * The transport (`send`) is injectable so the try-order / first-success / stop
85
89
  * orchestration is testable without a live SSH host; the default runs the real
86
- * `agents send --to owner` over SSH.
90
+ * explicit-destination `agents send` over SSH.
87
91
  */
88
- export async function forwardOwnerNotifyToPeer(text, channel, meta, opts = {}) {
92
+ export async function forwardOwnerNotifyToPeer(text, channel, target, meta, opts = {}) {
89
93
  // Cheap, I/O-free gate first: a box that already received a forward, or an
90
94
  // owner channel that isn't the macOS-only rush family, can never forward — so
91
95
  // a normal local success/failure never pays a device-registry disk read.
@@ -108,7 +112,7 @@ export async function forwardOwnerNotifyToPeer(text, channel, meta, opts = {}) {
108
112
  return undefined;
109
113
  const send = opts.send ?? sendOnPeer;
110
114
  for (const machine of plan.candidates) {
111
- const result = await send(machine, text);
115
+ const result = await send(machine, text, channel, target, opts.envelope);
112
116
  if (result?.ok)
113
117
  return result;
114
118
  }
@@ -33,6 +33,8 @@ export interface SendResult {
33
33
  attachments?: string[];
34
34
  /** Mailbox provider returns the enqueued message id. */
35
35
  msgId?: string;
36
+ /** Per-destination results when the owner policy selects multiple channels. */
37
+ deliveries?: SendResult[];
36
38
  }
37
39
  export interface ChannelProvider {
38
40
  /** Stable name used in `--channel` and as a `notify.transports` value. */
@@ -1,6 +1,7 @@
1
1
  import { getOwnerNotifyFromHumans } from '../humans.js';
2
2
  import { registerBuiltinProviders } from './providers/index.js';
3
3
  import { resolveTransport } from './resolve.js';
4
+ import { sendToOwner } from '../notify.js';
4
5
  const OWNER_ALIAS = 'owner';
5
6
  /** True when the destination token means “the configured owner”. */
6
7
  export function isOwnerAlias(to) {
@@ -112,6 +113,17 @@ export async function sendMessage(input, meta) {
112
113
  const resolved = resolveSendEnvelope(input, meta);
113
114
  if (!resolved.ok)
114
115
  return { error: resolved.error };
115
- const result = await deliverEnvelope(resolved.envelope, meta);
116
+ const ownerPolicyRequest = resolved.envelope.ownerScoped === true
117
+ && !input.channel?.trim()
118
+ && (!input.to?.trim() || isOwnerAlias(input.to));
119
+ const result = ownerPolicyRequest
120
+ ? await sendToOwner(resolved.envelope.text, {
121
+ meta,
122
+ dryRun: resolved.envelope.dryRun,
123
+ thread: resolved.envelope.thread,
124
+ attachments: resolved.envelope.attachments,
125
+ from: resolved.envelope.from,
126
+ })
127
+ : await deliverEnvelope(resolved.envelope, meta);
116
128
  return { result, envelope: resolved.envelope };
117
129
  }
@@ -21,6 +21,13 @@ export interface FeedSinkConfig {
21
21
  channel?: string;
22
22
  /** Recipient for a `channel` sink. Required unless `channel` is the `owner` alias. */
23
23
  to?: string;
24
+ /**
25
+ * Optional channel body template. Uses the same placeholders as `command`
26
+ * argv (`{message}`, `{ticket}`, `{project}`, ...). Defaults to `{message}`.
27
+ * A missing placeholder skips the sink, which lets a `{ticket}` template
28
+ * declare that only ticket-backed posts belong in that destination.
29
+ */
30
+ message?: string;
24
31
  /** Lowest post level that reaches this sink. Defaults to `milestone` (all posts). */
25
32
  minLevel?: FeedPostLevel;
26
33
  }
@@ -150,6 +157,8 @@ export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): stri
150
157
  * would otherwise comment on nothing.
151
158
  */
152
159
  export declare function renderSinkArgv(template: string[], ctx: FeedBroadcastContext): string[] | undefined;
160
+ /** Render one channel-message template with the same fail-closed placeholder contract as argv. */
161
+ export declare function renderSinkMessage(template: string, ctx: FeedBroadcastContext): string | undefined;
153
162
  /**
154
163
  * Which sinks this post reaches, in config order. Pure — the dry-run listing and
155
164
  * the real fan-out plan through here, so what `--dry-run` shows is what runs.
@@ -36,8 +36,8 @@
36
36
  import { spawnSync } from 'child_process';
37
37
  import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } from './channels/send.js';
38
38
  import { lookupTransport } from './channels/resolve.js';
39
- import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
40
39
  import { registerBuiltinProviders } from './channels/providers/index.js';
40
+ import { sendToOwner } from './notify.js';
41
41
  const LEVEL_RANK = { milestone: 0, important: 1 };
42
42
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
43
43
  export function parseFeedPostLevel(raw) {
@@ -324,6 +324,23 @@ export function renderSinkArgv(template, ctx) {
324
324
  }
325
325
  return argv.length > 0 ? argv : undefined;
326
326
  }
327
+ /** Render one channel-message template with the same fail-closed placeholder contract as argv. */
328
+ export function renderSinkMessage(template, ctx) {
329
+ const vars = templateVars(ctx);
330
+ let missing = false;
331
+ const rendered = template.replace(PLACEHOLDER, (whole, key) => {
332
+ const value = vars[key];
333
+ if (value === undefined || value === '') {
334
+ missing = true;
335
+ return whole;
336
+ }
337
+ return value;
338
+ });
339
+ if (missing)
340
+ return undefined;
341
+ const text = rendered.trim();
342
+ return text || undefined;
343
+ }
327
344
  /**
328
345
  * Which sinks this post reaches, in config order. Pure — the dry-run listing and
329
346
  * the real fan-out plan through here, so what `--dry-run` shows is what runs.
@@ -350,11 +367,14 @@ export function planFeedBroadcast(config, ctx) {
350
367
  // placeholder below).
351
368
  if (!isOwnerAlias(channel) && !sink.to?.trim())
352
369
  continue;
370
+ const text = renderSinkMessage(sink.message ?? '{message}', ctx);
371
+ if (!text)
372
+ continue;
353
373
  planned.push({
354
374
  name,
355
375
  channel,
356
376
  to: isOwnerAlias(channel) ? undefined : sink.to.trim(),
357
- text: composeBroadcastMessage(ctx),
377
+ text,
358
378
  });
359
379
  continue;
360
380
  }
@@ -462,6 +482,10 @@ async function runChannelSink(sink, meta) {
462
482
  // for a name that is, in fact, registered.
463
483
  registerBuiltinProviders();
464
484
  const owner = isOwnerAlias(sink.channel);
485
+ if (owner) {
486
+ const result = await sendToOwner(sink.text ?? '', { meta });
487
+ return { name, ok: result.ok, ...(result.error ? { error: result.error } : {}) };
488
+ }
465
489
  const resolved = resolveSendEnvelope({
466
490
  text: sink.text ?? '',
467
491
  channel: owner ? undefined : sink.channel,
@@ -476,17 +500,6 @@ async function runChannelSink(sink, meta) {
476
500
  const result = await deliverEnvelope(resolved.envelope, meta);
477
501
  if (result.ok)
478
502
  return { name, ok: true };
479
- // The owner sink failed locally. When this box structurally cannot reach the
480
- // owner (the rush-backed channel is macOS-only, so a headless Linux worker can
481
- // never ring the phone — PHNX-3303), hand the delivery to a capable fleet peer
482
- // over SSH rather than stranding the important post. Only the `owner` alias
483
- // forwards: it resolves the peer's own fleet-synced owner destination, so a
484
- // non-owner channel sink with an explicit recipient stays local.
485
- if (owner) {
486
- const forwarded = await forwardOwnerNotifyToPeer(sink.text ?? '', resolved.envelope.channel, meta);
487
- if (forwarded?.ok)
488
- return { name, ok: true };
489
- }
490
503
  return { name, ok: false, error: result.error };
491
504
  }
492
505
  /**
@@ -20,6 +20,15 @@ export declare function getOwnerNotifyFromHumans(): {
20
20
  channel: string;
21
21
  to: string;
22
22
  } | null;
23
+ /**
24
+ * Return every addressable owner destination selected by the normal-severity
25
+ * policy, in policy order. A config without a policy keeps the historical
26
+ * single-channel behavior by selecting the first addressable channel.
27
+ */
28
+ export declare function getOwnerNotifyDestinationsFromHumans(): Array<{
29
+ channel: string;
30
+ to: string;
31
+ }>;
23
32
  /**
24
33
  * Read the owner block from humans.yaml. Returns null if missing.
25
34
  */
@@ -51,19 +51,40 @@ export function writeHumans(config) {
51
51
  * policy is declared, the first addressable channel is the default.
52
52
  */
53
53
  export function getOwnerNotifyFromHumans() {
54
+ return getOwnerNotifyDestinationsFromHumans()[0] ?? null;
55
+ }
56
+ /**
57
+ * Return every addressable owner destination selected by the normal-severity
58
+ * policy, in policy order. A config without a policy keeps the historical
59
+ * single-channel behavior by selecting the first addressable channel.
60
+ */
61
+ export function getOwnerNotifyDestinationsFromHumans() {
54
62
  const owner = readHumans()?.owner;
55
63
  const channels = owner?.channels ?? [];
56
64
  const preferredIds = owner?.policy?.normal ?? [];
57
- const preferred = preferredIds
58
- .map((id) => channels.find((entry) => entry.id === id))
59
- .find((entry) => entry?.to);
60
- const selected = preferred ?? channels.find((entry) => entry.to);
61
- if (selected?.id && selected.to)
62
- return { channel: selected.id, to: selected.to };
65
+ const selected = preferredIds.length > 0
66
+ ? preferredIds.map((id) => channels.find((entry) => entry.id === id))
67
+ : [channels.find((entry) => entry.to)];
68
+ const seen = new Set();
69
+ const destinations = selected
70
+ .filter((entry) => Boolean(entry?.id && entry.to))
71
+ .map((entry) => ({ channel: entry.id, to: entry.to }))
72
+ .filter((entry) => {
73
+ const key = `${entry.channel}\0${entry.to}`;
74
+ if (seen.has(key))
75
+ return false;
76
+ seen.add(key);
77
+ return true;
78
+ });
79
+ if (destinations.length > 0)
80
+ return destinations;
81
+ const firstAddressable = channels.find((entry) => entry.id && entry.to);
82
+ if (firstAddressable?.to)
83
+ return [{ channel: firstAddressable.id, to: firstAddressable.to }];
63
84
  const migrated = owner?.notify;
64
85
  if (migrated?.channel && migrated.to)
65
- return migrated;
66
- return null;
86
+ return [migrated];
87
+ return [];
67
88
  }
68
89
  /**
69
90
  * Read the owner block from humans.yaml. Returns null if missing.
@@ -25,6 +25,9 @@ export interface OwnerNotifyOptions {
25
25
  target?: string;
26
26
  /** Resolve + build the delivery but do not actually send. */
27
27
  dryRun?: boolean;
28
+ thread?: string;
29
+ attachments?: string[];
30
+ from?: string;
28
31
  }
29
32
  export interface NotifyResult {
30
33
  ok: boolean;
@@ -1,5 +1,5 @@
1
1
  import { readMeta } from './state.js';
2
- import { getOwnerNotifyFromHumans } from './humans.js';
2
+ import { getOwnerNotifyDestinationsFromHumans } from './humans.js';
3
3
  import { registerBuiltinProviders } from './channels/providers/index.js';
4
4
  import { lookupTransport } from './channels/resolve.js';
5
5
  import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
@@ -49,33 +49,75 @@ export function buildOpenClawNotifyArgs(text, opts) {
49
49
  */
50
50
  export async function sendToOwner(text, options = {}) {
51
51
  const meta = options.meta ?? readMeta();
52
- const owner = getOwnerNotifyFromHumans() ?? meta.notify?.owner;
53
- const channel = options.channel ?? owner?.channel;
54
- const target = options.target ?? owner?.to;
55
- if (!channel || !target) {
52
+ const canonical = getOwnerNotifyDestinationsFromHumans();
53
+ const legacy = meta.notify?.owner ? [meta.notify.owner] : [];
54
+ const configured = canonical.length > 0 ? canonical : legacy;
55
+ const destinations = options.target
56
+ ? [{ channel: options.channel ?? configured[0]?.channel, to: options.target }]
57
+ : options.channel
58
+ ? [configured.find((dest) => dest.channel === options.channel) ?? {
59
+ channel: options.channel,
60
+ to: configured[0]?.to,
61
+ }]
62
+ : configured;
63
+ const addressable = destinations.filter((dest) => Boolean(dest.channel && dest.to));
64
+ if (addressable.length === 0) {
56
65
  return {
57
66
  ok: false,
58
- channel: channel ?? 'unknown',
59
- id: target ?? '',
67
+ channel: options.channel ?? 'unknown',
68
+ id: options.target ?? '',
60
69
  error: 'No addressable owner channel configured in humans.yaml or legacy notify.owner',
61
70
  };
62
71
  }
63
72
  registerBuiltinProviders();
64
- const { provider, error } = lookupTransport(channel, meta);
65
- if (!provider) {
66
- return { ok: false, channel, id: target, error };
73
+ const deliveries = [];
74
+ for (const { channel, to: target } of addressable) {
75
+ const { provider, error } = lookupTransport(channel, meta);
76
+ let result;
77
+ try {
78
+ result = provider
79
+ ? await provider.send(text, {
80
+ target,
81
+ ownerScoped: options.target === undefined,
82
+ dryRun: options.dryRun,
83
+ thread: options.thread,
84
+ attachments: options.attachments,
85
+ from: options.from,
86
+ })
87
+ : { ok: false, channel, id: target, error };
88
+ }
89
+ catch (err) {
90
+ result = { ok: false, channel, id: target, error: err.message };
91
+ }
92
+ // A dry-run never delivers, and an override target is an explicit recipient
93
+ // (not the fleet-wide owner) — neither should hop to a peer.
94
+ if (!result.ok && !options.dryRun && options.target === undefined) {
95
+ if (options.attachments?.length) {
96
+ result = {
97
+ ...result,
98
+ error: `${result.error ?? 'local delivery failed'}; owner attachments cannot be forwarded to another device`,
99
+ };
100
+ }
101
+ else {
102
+ result = await forwardOwnerNotifyToPeer(text, channel, target, meta, {
103
+ envelope: { thread: options.thread, from: options.from },
104
+ }) ?? result;
105
+ }
106
+ }
107
+ deliveries.push(result);
67
108
  }
68
- const local = await provider.send(text, {
69
- target,
70
- ownerScoped: options.target === undefined,
71
- dryRun: options.dryRun,
72
- });
73
- // A dry-run never delivers, and an override target is an explicit recipient
74
- // (not the fleet-wide owner) — neither should hop to a peer.
75
- if (local.ok || options.dryRun || options.target !== undefined)
76
- return local;
77
- const forwarded = await forwardOwnerNotifyToPeer(text, channel, meta);
78
- return forwarded ?? local;
109
+ if (deliveries.length === 1)
110
+ return deliveries[0];
111
+ const failures = deliveries.filter((result) => !result.ok);
112
+ return {
113
+ ok: deliveries.some((result) => result.ok),
114
+ channel: 'owner',
115
+ id: deliveries.map((result) => `${result.channel}:${result.id}`).join(','),
116
+ ...(failures.length > 0
117
+ ? { error: failures.map((result) => `${result.channel}: ${result.error ?? 'failed'}`).join('; ') }
118
+ : {}),
119
+ deliveries,
120
+ };
79
121
  }
80
122
  export async function notifyUrgentBlock(block, options = {}) {
81
123
  if (block.notifiedAt) {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Resource inventory — the single chokepoint for "what does this agent@version
3
- * have" (RUSH-2238, parent RUSH-2236; spec: openspec/specs/resource-inventory).
3
+ * have" (RUSH-2238, parent RUSH-2236). Contract: cli/docs/specifications.md.
4
4
  *
5
5
  * One API answers four orthogonal questions per (agent, version, kind):
6
6
  *
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Resource inventory — the single chokepoint for "what does this agent@version
3
- * have" (RUSH-2238, parent RUSH-2236; spec: openspec/specs/resource-inventory).
3
+ * have" (RUSH-2238, parent RUSH-2236). Contract: cli/docs/specifications.md.
4
4
  *
5
5
  * One API answers four orthogonal questions per (agent, version, kind):
6
6
  *
@@ -251,21 +251,70 @@ export default {
251
251
  // segments deep (an unsupported shape outside the CLI) landing on the same
252
252
  // literal revision key.
253
253
  const noRevision = !!request.headers.get('x-share-no-revision');
254
- if (!noRevision) {
255
- const existing = await env.BUCKET.get(path);
256
- if (existing) {
257
- const existingHeaders = new Headers();
258
- if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(existingHeaders);
259
- const existingContentType = existingHeaders.get('content-type');
260
- const revKey = path + '/rev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
261
- await env.BUCKET.put(revKey, existing.body, {
262
- httpMetadata: existingContentType ? { contentType: existingContentType } : undefined,
263
- customMetadata: existing.customMetadata || {},
264
- });
254
+ // PHNX-3542: per-user storage quota + object count + per-file size cap +
255
+ // publish rate limit, enforced ONLY for a managed Phoenix identity. A BYO
256
+ // WRITE_TOKEN publish writes to the operator's OWN bucket at their own
257
+ // cost, so it skips all four — a deliberate, documented policy, NOT a
258
+ // silent no-op. The current object is needed for BOTH the revision copy and
259
+ // the charge math, so read it once here; a BYO no-revision publish still
260
+ // skips the read entirely (nothing consumes it).
261
+ const needExisting = !noRevision || auth.kind === 'phoenix';
262
+ const existing = needExisting ? await env.BUCKET.get(path) : null;
263
+ // Enforcement measures the REAL request body, never a client-declared size.
264
+ // A spoofed-low content-length must NOT (a) slip an oversized body past the
265
+ // per-file cap, (b) let real bytes exceed the total quota, or — most
266
+ // dangerously — (c) reach the destructive revision-copy + canonical
267
+ // overwrite before the size is known and DESTROY the existing page. So for a
268
+ // Phoenix write we buffer the body bounded by the plan's per-file cap and
269
+ // reject on the REAL size BEFORE any write; only then do we copy the
270
+ // revision and store the buffered bytes. BYO streams unbuffered (uncapped,
271
+ // its own bucket).
272
+ let putBody = request.body;
273
+ if (auth.kind === 'phoenix') {
274
+ const limits = planLimits((await readUsage(env, auth.owner)).usage.plan);
275
+ // Fast-reject an HONEST oversized content-length without reading the body.
276
+ // A dishonest (absent or lied-low) length falls through to the bounded
277
+ // read below, which measures the truth.
278
+ const declaredLen = parseInt(request.headers.get('content-length') || '', 10);
279
+ if (Number.isFinite(declaredLen) && declaredLen > limits.maxFileBytes) {
280
+ return json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: declaredLen }, 413);
281
+ }
282
+ // readBodyBounded aborts the moment it passes the cap, so a chunked/
283
+ // streaming body can never buffer more than the cap (+ one chunk).
284
+ const read = await readBodyBounded(request, limits.maxFileBytes);
285
+ if (read.oversize) {
286
+ return json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: read.size }, 413);
265
287
  }
288
+ const realBytes = read.size;
289
+ const existingSize = existing && typeof existing.size === 'number' ? existing.size : 0;
290
+ const newCanonical = !existing;
291
+ // Keeping a revision retains the old canonical bytes AND adds the new
292
+ // ones, so storage grows by the full new size. A no-revision or first
293
+ // publish grows by new minus the bytes it replaces (may be negative on a
294
+ // shrink; the ledger clamps at >= 0).
295
+ const charge = (!noRevision && existing) ? realBytes : realBytes - existingSize;
296
+ const charged = await chargeShareWrite(env, auth, {
297
+ charge: charge,
298
+ newCanonical: newCanonical,
299
+ fileBytes: realBytes,
300
+ countRate: true,
301
+ });
302
+ if (charged.error) return charged.error; // rejected BEFORE any destructive write
303
+ putBody = read.bytes;
266
304
  }
267
305
 
268
- await env.BUCKET.put(path, request.body, {
306
+ if (!noRevision && existing) {
307
+ const existingHeaders = new Headers();
308
+ if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(existingHeaders);
309
+ const existingContentType = existingHeaders.get('content-type');
310
+ const revKey = path + '/rev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
311
+ await env.BUCKET.put(revKey, existing.body, {
312
+ httpMetadata: existingContentType ? { contentType: existingContentType } : undefined,
313
+ customMetadata: existing.customMetadata || {},
314
+ });
315
+ }
316
+
317
+ await env.BUCKET.put(path, putBody, {
269
318
  httpMetadata: { contentType },
270
319
  customMetadata,
271
320
  });
@@ -508,6 +557,22 @@ export default {
508
557
  const expiresAt = obj.customMetadata && obj.customMetadata['expires-at'];
509
558
  if (expiresAt && Date.now() > Date.parse(expiresAt)) {
510
559
  await env.BUCKET.delete(path);
560
+ // PHNX-3542: expiry is a lazy delete with no auth context. Refund the
561
+ // stamped owner's quota (bytes + object count) best-effort — a refund
562
+ // failure must never break serving the 410. A canonical page is 2
563
+ // segments; covers are excluded (never charged) so refund is safe here
564
+ // because only canonical pages ever carry an expires-at.
565
+ const expiredOwner = obj.customMetadata && obj.customMetadata['owner'];
566
+ if (expiredOwner && !path.endsWith('.png')) {
567
+ try {
568
+ await refundShareWrite(env, expiredOwner, {
569
+ refund: typeof obj.size === 'number' ? obj.size : 0,
570
+ freeCanonical: path.split('/').filter(Boolean).length === 2,
571
+ });
572
+ } catch (e) {
573
+ // best-effort: serving the expiry 410 must never depend on the refund
574
+ }
575
+ }
511
576
  return new Response('gone — this link has expired', { status: 410, headers: { 'content-type': 'text/plain' } });
512
577
  }
513
578
  // Resolve the viewer ONCE — needed both to gate me/org reads AND to decide
@@ -615,7 +680,24 @@ export default {
615
680
  return json({ error: 'namespace mismatch', owner: handle || uid }, 403);
616
681
  }
617
682
  }
683
+ // PHNX-3542: capture the object's size BEFORE deleting so a managed
684
+ // Phoenix owner's quota can be refunded. A canonical page (2 segments, not
685
+ // a .png cover) refunds both bytes and the object count; a retained
686
+ // revision (3+ segments) refunds bytes only; a server-generated .png cover
687
+ // was never charged, so it is skipped entirely.
688
+ let doomed = null;
689
+ const isCover = path.endsWith('.png');
690
+ if (auth.kind === 'phoenix' && !isCover && !firstSeg.startsWith('__')) {
691
+ doomed = typeof env.BUCKET.head === 'function' ? await env.BUCKET.head(path) : await env.BUCKET.get(path);
692
+ }
618
693
  await env.BUCKET.delete(path);
694
+ if (doomed) {
695
+ const delSegs = path.split('/').filter(Boolean);
696
+ await refundShareWrite(env, auth.owner, {
697
+ refund: typeof doomed.size === 'number' ? doomed.size : 0,
698
+ freeCanonical: delSegs.length === 2,
699
+ });
700
+ }
619
701
  return json({ ok: true, deleted: path }, 200);
620
702
  }
621
703
 
@@ -819,6 +901,23 @@ var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotma
819
901
  var SHARE_COOKIE = '__Host-phoenix_share';
820
902
  var SHARE_COOKIE_MAX_AGE = 604800;
821
903
 
904
+ // PHNX-3542 — per-user storage limits for the MANAGED share endpoint. The plan
905
+ // map is the seam for future paid tiers: only 'free' is defined today, and a
906
+ // ledger with no plan (or an unknown one) resolves to it via planLimits(). Paid
907
+ // tiers and the write path that sets a user's plan arrive with billing (follow-up
908
+ // PHNX-3569); this is a legitimately-deferred seam, not a stub — 'free' IS
909
+ // enforced. Covers/views are server-generated overhead and excluded from the
910
+ // quota, which counts canonical pages + their retained revisions only.
911
+ var MiB = 1024 * 1024;
912
+ var SHARE_PLANS = {
913
+ free: { maxBytes: 200 * MiB, maxObjects: 150, maxFileBytes: 20 * MiB, ratePerHour: 60 },
914
+ };
915
+ var DEFAULT_SHARE_PLAN = 'free';
916
+ var RATE_WINDOW_MS = 3600 * 1000; // fixed 1h publish-rate window
917
+ function planLimits(plan) { return SHARE_PLANS[plan] || SHARE_PLANS[DEFAULT_SHARE_PLAN]; }
918
+ function freshUsage() { return { bytes: 0, count: 0, plan: DEFAULT_SHARE_PLAN, rlStart: 0, rlUsed: 0 }; }
919
+ function usageNumber(v, fallback) { return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback; }
920
+
822
921
  // Everything in customMetadata that ISN'T one of the reserved provenance/label
823
922
  // keys above — i.e. the caller's own \`--meta key=value\` entries. Surfaced on
824
923
  // every read route (listing, revisions) so a value stored with \`--meta
@@ -1313,6 +1412,163 @@ async function writeViews(env, path, count) {
1313
1412
  }
1314
1413
  }
1315
1414
 
1415
+ // --- PHNX-3542 per-user usage ledger (R2 conditional-put CAS) ---------------
1416
+ // The ledger is a single R2 object at __usage/<owner> holding the running
1417
+ // { bytes, count, plan, rlStart, rlUsed } for one user. It mirrors the __views /
1418
+ // __handles precedent: a __-prefixed key, GET-blocked and outside every gallery/
1419
+ // listing/revision prefix. R2 has no atomic increment, so mutations use a
1420
+ // read → mutate → conditional-put loop (onlyIf.etagMatches), the same primitive
1421
+ // the PATCH metadata-edit path already relies on.
1422
+ function usageKey(owner) { return '__usage/' + sanitizeNamespace(owner); }
1423
+
1424
+ async function readUsage(env, owner) {
1425
+ const obj = await env.BUCKET.get(usageKey(owner));
1426
+ if (!obj) return { etag: null, usage: freshUsage() };
1427
+ let usage = freshUsage();
1428
+ try {
1429
+ const raw = typeof obj.text === 'function' ? await obj.text() : '';
1430
+ const parsed = JSON.parse(raw);
1431
+ if (parsed && typeof parsed === 'object') {
1432
+ usage = {
1433
+ bytes: usageNumber(parsed.bytes, 0),
1434
+ count: usageNumber(parsed.count, 0),
1435
+ plan: typeof parsed.plan === 'string' ? parsed.plan : DEFAULT_SHARE_PLAN,
1436
+ rlStart: usageNumber(parsed.rlStart, 0),
1437
+ rlUsed: usageNumber(parsed.rlUsed, 0),
1438
+ };
1439
+ }
1440
+ } catch (e) {
1441
+ // Malformed ledger — treat as fresh zero but KEEP the etag so the next CAS
1442
+ // put overwrites the corrupt object rather than looping against it forever.
1443
+ }
1444
+ return { etag: obj.etag || null, usage: usage };
1445
+ }
1446
+
1447
+ async function writeUsageCas(env, owner, prevEtag, usage) {
1448
+ const body = JSON.stringify(usage);
1449
+ const opts = { httpMetadata: { contentType: 'application/json' } };
1450
+ if (prevEtag) {
1451
+ // Existing object: conditional put. R2 returns null when the etag no longer
1452
+ // matches (a concurrent writer won the race) → report failure so the caller
1453
+ // re-reads and retries.
1454
+ opts.onlyIf = { etagMatches: prevEtag };
1455
+ const res = await env.BUCKET.put(usageKey(owner), body, opts);
1456
+ return res !== null;
1457
+ }
1458
+ // Fresh key: plain create. Neither R2 nor the test harness exposes a
1459
+ // conditional-create predicate, so the only race is two simultaneous
1460
+ // first-creates of the same owner's ledger — a benign, one-time bounded loss.
1461
+ await env.BUCKET.put(usageKey(owner), body, opts);
1462
+ return true;
1463
+ }
1464
+
1465
+ // CAS retry loop. fn(usage) returns { reject: Response } to fail loud without
1466
+ // committing, or { commit: usage, result } to persist and return result. On CAS
1467
+ // contention it re-reads and retries; exhausting the retries fails loud with 503.
1468
+ async function withUsage(env, owner, fn) {
1469
+ for (let attempt = 0; attempt < 6; attempt++) {
1470
+ const state = await readUsage(env, owner);
1471
+ const outcome = fn(state.usage);
1472
+ if (outcome.reject) return outcome.reject;
1473
+ const ok = await writeUsageCas(env, owner, state.etag, outcome.commit);
1474
+ if (ok) return outcome.result;
1475
+ }
1476
+ return json({ error: 'usage ledger contended, retry' }, 503);
1477
+ }
1478
+
1479
+ function rateLimited(retryAfterSec, ratePerHour) {
1480
+ return new Response(
1481
+ JSON.stringify({ error: 'rate limit: too many publishes, retry later', retryAfterSec: retryAfterSec, ratePerHour: ratePerHour }),
1482
+ { status: 429, headers: { 'content-type': 'application/json', 'retry-after': String(retryAfterSec) } },
1483
+ );
1484
+ }
1485
+
1486
+ // Charge one authed PUT against the owner's ledger. Rate → per-file cap → object
1487
+ // count → byte quota, each failing loud (429 / 413) before anything is written.
1488
+ // Returns { error: Response } on any rejection, else { limits } (the resolved
1489
+ // plan limits, reused by the post-write real-size reconcile). Managed only: the
1490
+ // caller guards on auth.kind === 'phoenix'.
1491
+ async function chargeShareWrite(env, auth, params) {
1492
+ const now = Date.now();
1493
+ const out = await withUsage(env, auth.owner, function (usage) {
1494
+ const limits = planLimits(usage.plan);
1495
+ // Rate limit — only a user-initiated page PUT counts (countRate). A fixed 1h
1496
+ // window: reset when the window has rolled over, otherwise reject at the cap.
1497
+ if (params.countRate) {
1498
+ if (now - usage.rlStart >= RATE_WINDOW_MS) { usage.rlStart = now; usage.rlUsed = 0; }
1499
+ if (usage.rlUsed >= limits.ratePerHour) {
1500
+ const retryAfterSec = Math.max(1, Math.ceil((usage.rlStart + RATE_WINDOW_MS - now) / 1000));
1501
+ return { reject: rateLimited(retryAfterSec, limits.ratePerHour) };
1502
+ }
1503
+ usage.rlUsed += 1;
1504
+ }
1505
+ // Per-file size cap.
1506
+ if (typeof params.fileBytes === 'number' && params.fileBytes > limits.maxFileBytes) {
1507
+ return { reject: json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: params.fileBytes }, 413) };
1508
+ }
1509
+ // Object (canonical page) count.
1510
+ if (params.newCanonical) {
1511
+ if (usage.count + 1 > limits.maxObjects) {
1512
+ return { reject: json({ error: 'artifact limit reached', maxObjects: limits.maxObjects }, 413) };
1513
+ }
1514
+ usage.count += 1;
1515
+ }
1516
+ // Total byte quota. charge may be negative on a shrink — clamp at >= 0.
1517
+ if (usage.bytes + params.charge > limits.maxBytes) {
1518
+ return { reject: json({ error: 'storage limit reached', maxBytes: limits.maxBytes, usedBytes: usage.bytes }, 413) };
1519
+ }
1520
+ usage.bytes = Math.max(0, usage.bytes + params.charge);
1521
+ return { commit: usage, result: { limits: limits } };
1522
+ });
1523
+ if (out instanceof Response) return { error: out };
1524
+ return { limits: out.limits };
1525
+ }
1526
+
1527
+ // Read a request body fully into memory, BOUNDED: abort the moment it exceeds
1528
+ // maxBytes, so a chunked/streaming body can never buffer more than the cap (plus
1529
+ // one in-flight chunk) and OOM the Worker. Returns { oversize: true, size } once
1530
+ // the cap is passed (size is a lower bound, >= cap), else { bytes, size } with
1531
+ // the exact bytes. A body-less request yields an empty buffer. This is what lets
1532
+ // enforcement key on the REAL size instead of a spoofable declared header.
1533
+ async function readBodyBounded(request, maxBytes) {
1534
+ if (!request.body || typeof request.body.getReader !== 'function') {
1535
+ const buf = await request.arrayBuffer();
1536
+ const bytes = new Uint8Array(buf);
1537
+ if (bytes.byteLength > maxBytes) return { oversize: true, size: bytes.byteLength };
1538
+ return { bytes: bytes, size: bytes.byteLength };
1539
+ }
1540
+ const reader = request.body.getReader();
1541
+ const chunks = [];
1542
+ let size = 0;
1543
+ while (true) {
1544
+ const step = await reader.read();
1545
+ if (step.done) break;
1546
+ const chunk = step.value;
1547
+ size += chunk.byteLength;
1548
+ if (size > maxBytes) {
1549
+ try { await reader.cancel(); } catch (e) { /* best-effort */ }
1550
+ return { oversize: true, size: size };
1551
+ }
1552
+ chunks.push(chunk);
1553
+ }
1554
+ const out = new Uint8Array(size);
1555
+ let offset = 0;
1556
+ for (let i = 0; i < chunks.length; i++) { out.set(chunks[i], offset); offset += chunks[i].byteLength; }
1557
+ return { bytes: out, size: size };
1558
+ }
1559
+
1560
+ // Refund on DELETE / expiry. Never rejects, never creates a ledger: if the owner
1561
+ // was never charged (no __usage object) it is a pure no-op.
1562
+ async function refundShareWrite(env, owner, params) {
1563
+ for (let attempt = 0; attempt < 6; attempt++) {
1564
+ const state = await readUsage(env, owner);
1565
+ if (!state.etag) return; // no ledger — owner never charged, nothing to refund
1566
+ state.usage.bytes = Math.max(0, state.usage.bytes - (params.refund || 0));
1567
+ if (params.freeCanonical) state.usage.count = Math.max(0, state.usage.count - 1);
1568
+ if (await writeUsageCas(env, owner, state.etag, state.usage)) return;
1569
+ }
1570
+ }
1571
+
1316
1572
  function viewerMayRead(visibility, meta, identity) {
1317
1573
  if (visibility === 'me') {
1318
1574
  const owner = meta && meta.owner;
@@ -929,7 +929,9 @@ export interface Meta {
929
929
  /**
930
930
  * `agents feed post` fan-out. `broadcast` maps a sink name to either an argv
931
931
  * template (`command:`, run for each post) or an in-process channel delivery
932
- * (`channel:`, the same registry `agents send`/`agents notify` use), so
932
+ * (`channel:`, the same registry `agents send`/`agents notify` use). Channel
933
+ * sinks may set `message:` with feed placeholders; a missing placeholder
934
+ * skips that sink, so `{ticket}` cleanly gates a tracker-specific channel. Thus
933
935
  * mirroring to a tracker, a messaging CLI, or a channel provider is the
934
936
  * operator's config rather than an integration compiled into this CLI. When
935
937
  * this is unset/empty, an important-level post falls back to `notify.owner`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.61",
3
+ "version": "1.22.63",
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",