@phnx-labs/agents-cli 1.22.68 → 1.22.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +14 -6
  3. package/dist/bootstrap.js +3 -0
  4. package/dist/commands/exec.js +31 -21
  5. package/dist/commands/feed.js +20 -7
  6. package/dist/commands/monitors.js +3 -0
  7. package/dist/commands/projects.d.ts +26 -6
  8. package/dist/commands/projects.js +55 -22
  9. package/dist/commands/send.js +29 -2
  10. package/dist/commands/sessions-inject.d.ts +58 -0
  11. package/dist/commands/sessions-inject.js +143 -7
  12. package/dist/commands/sessions-picker.js +1 -0
  13. package/dist/commands/share.js +43 -14
  14. package/dist/commands/ssh.js +205 -2
  15. package/dist/lib/accounting/usage.d.ts +7 -2
  16. package/dist/lib/accounting/usage.js +142 -10
  17. package/dist/lib/boot-profile.d.ts +14 -0
  18. package/dist/lib/boot-profile.js +66 -0
  19. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  20. package/dist/lib/channels/providers/desktop.js +5 -4
  21. package/dist/lib/claude-account-token.js +108 -4
  22. package/dist/lib/devices/health.d.ts +38 -2
  23. package/dist/lib/devices/health.js +43 -5
  24. package/dist/lib/devices/worker-pick.d.ts +1 -1
  25. package/dist/lib/devices/worker-pick.js +4 -1
  26. package/dist/lib/exec.js +4 -0
  27. package/dist/lib/feed-broadcast.d.ts +64 -5
  28. package/dist/lib/feed-broadcast.js +124 -22
  29. package/dist/lib/monitors/engine.js +18 -0
  30. package/dist/lib/monitors/sources/command.js +13 -3
  31. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  32. package/dist/lib/monitors/sources/failure.js +52 -0
  33. package/dist/lib/monitors/sources/types.d.ts +9 -0
  34. package/dist/lib/owner-message.d.ts +12 -0
  35. package/dist/lib/owner-message.js +44 -0
  36. package/dist/lib/run-trace-sync.d.ts +15 -0
  37. package/dist/lib/run-trace-sync.js +43 -21
  38. package/dist/lib/secrets/filestore.d.ts +4 -0
  39. package/dist/lib/secrets/filestore.js +164 -3
  40. package/dist/lib/session/active.d.ts +10 -0
  41. package/dist/lib/session/active.js +3 -0
  42. package/dist/lib/session/db.d.ts +12 -1
  43. package/dist/lib/session/db.js +20 -1
  44. package/dist/lib/session/discover.js +81 -1
  45. package/dist/lib/session/linear.d.ts +13 -0
  46. package/dist/lib/session/linear.js +44 -0
  47. package/dist/lib/session/live-metadata.js +1 -0
  48. package/dist/lib/session/parse.js +2 -3
  49. package/dist/lib/session/prompt.d.ts +7 -1
  50. package/dist/lib/session/prompt.js +12 -2
  51. package/dist/lib/session/recovery.d.ts +21 -12
  52. package/dist/lib/session/recovery.js +29 -11
  53. package/dist/lib/session/remote/watch.js +5 -2
  54. package/dist/lib/session/state.js +11 -13
  55. package/dist/lib/share/backend.d.ts +2 -2
  56. package/dist/lib/share/backend.js +20 -9
  57. package/dist/lib/share/delete.d.ts +5 -1
  58. package/dist/lib/share/delete.js +7 -2
  59. package/dist/lib/share/http-error.d.ts +52 -0
  60. package/dist/lib/share/http-error.js +65 -0
  61. package/dist/lib/share/publish.d.ts +13 -3
  62. package/dist/lib/share/publish.js +19 -15
  63. package/dist/lib/share/worker-template.js +5 -1
  64. package/dist/lib/smart-launch.js +27 -4
  65. package/dist/lib/storage/index.d.ts +14 -0
  66. package/dist/lib/storage/index.js +14 -0
  67. package/dist/lib/storage/selection.d.ts +48 -0
  68. package/dist/lib/storage/selection.js +39 -0
  69. package/dist/lib/storage/visibility.d.ts +82 -0
  70. package/dist/lib/storage/visibility.js +99 -0
  71. package/dist/lib/teams/agents.js +3 -1
  72. package/dist/lib/teams/placement-probe.js +1 -0
  73. package/dist/lib/teams/scheduler.d.ts +8 -1
  74. package/dist/lib/teams/scheduler.js +4 -1
  75. package/dist/lib/traces/backend.js +13 -2
  76. package/dist/lib/worktree/held.d.ts +166 -0
  77. package/dist/lib/worktree/held.js +368 -0
  78. package/package.json +2 -2
@@ -136,11 +136,56 @@ export interface SinkOutcome {
136
136
  export declare function shortHost(host: string | undefined): string | undefined;
137
137
  /** First 8 hex chars of a session id for the footer (readable, not a full uuid). */
138
138
  export declare function shortSessionChunk(session: string | undefined): string | undefined;
139
+ /**
140
+ * Tap-to-view link for the session behind a post: the addressable console page
141
+ * ({@link https://prix.dev/console/sessions/<id>}, prix/web). The footer already
142
+ * carries a short session crumb for disambiguation; this rides the link trail so
143
+ * the owner can open the full transcript straight from an iMessage broadcast
144
+ * instead of hunting for it in the console.
145
+ *
146
+ * Accepts any real, path-safe session id — a Claude/Codex UUID *and* a native
147
+ * `ses_…` id from OpenCode or another harness. The console shard uploader
148
+ * (`traces/sync.ts`) syncs sessions with no harness filter, so all of them are
149
+ * addressable; a UUID-only gate would silently drop the link for every non-Claude
150
+ * harness (the whole point of the link). Reject only an id that could not resolve:
151
+ * one with a path separator (URL-unsafe, via {@link isValidMailboxId}) or the bare
152
+ * 8-char footer crumb (a truncated id that would 404).
153
+ */
154
+ export declare function sessionConsoleUrl(session: string | undefined): string | undefined;
139
155
  /**
140
156
  * Scrub em/en dashes from outbound phone copy (house rule + iMessage readability).
141
157
  * Collapses whitespace; does not invent meaning.
142
158
  */
143
159
  export declare function scrubOutboundDashes(text: string): string;
160
+ /**
161
+ * The rendering vocabulary a sink can display, which decides how the shared
162
+ * `{message}` surfaces its links (PHNX-3698):
163
+ *
164
+ * - `mrkdwn` — Slack, which renders `<url|label>` as blue tappable text. The
165
+ * session crumb and every ticket key the prose NAMES become inline labeled
166
+ * links, so nothing rides a trailing naked-URL line.
167
+ * - `plain` — iMessage, the owner-scoped rush message, a spawned `command:`
168
+ * sink, desktop banners: none can render a labeled link and a dumped naked
169
+ * URL reads as noise, so the message stays the human sentence with no URLs.
170
+ *
171
+ * The default is `plain`; only a Slack `channel:` sink opts into `mrkdwn`.
172
+ */
173
+ export type SinkMessageFormat = 'plain' | 'mrkdwn';
174
+ /**
175
+ * Only Slack renders `<url|label>`, so it is the one format that gets labeled
176
+ * links. iMessage / owner-scoped rush / command / desktop sinks stay `plain`
177
+ * (they can't turn `claude/6fc1db18` blue, and dumping the raw URL is worse than
178
+ * leaving the crumb unlinked — PHNX-3698).
179
+ *
180
+ * The argument is the **resolved provider name**, not the sink's declared
181
+ * channel: an operator can point an arbitrary channel name at the Slack provider
182
+ * through `notify.transports` (e.g. `eng-alerts -> slack`), and delivery keys off
183
+ * that resolved provider (`lookupTransport`), so the format decision must too —
184
+ * otherwise an aliased Slack sink would compose plain while delivering to Slack,
185
+ * or a name remapped AWAY from Slack would emit `<url|label>` markup a non-Slack
186
+ * transport shows literally. {@link resolveSinkProvider} does the mapping.
187
+ */
188
+ export declare function sinkMessageFormat(provider: string | undefined): SinkMessageFormat;
144
189
  /**
145
190
  * Footer like "Sent from my iPhone" — who posted, a session crumb, which box.
146
191
  *
@@ -148,10 +193,15 @@ export declare function scrubOutboundDashes(text: string): string;
148
193
  *
149
194
  * Agent name first; session chunk for disambiguation when many groks run;
150
195
  * host last. Skip the uninformative default label `agent`.
196
+ *
197
+ * In `mrkdwn` the crumb (`agent/short`) becomes a Slack labeled link to the
198
+ * session's console page, so the human sentence reads identically while the
199
+ * crumb turns blue and taps through (PHNX-3698). `plain` keeps the bare sentence
200
+ * — it can't render a labeled link and must not dump the URL.
151
201
  */
152
- export declare function composeBroadcastFooter(ctx: FeedBroadcastContext): string | undefined;
202
+ export declare function composeBroadcastFooter(ctx: FeedBroadcastContext, format?: SinkMessageFormat): string | undefined;
153
203
  export declare function truncateBroadcastBody(body: string): string;
154
- export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): string;
204
+ export declare function composeBroadcastMessage(ctx: FeedBroadcastContext, format?: SinkMessageFormat): string;
155
205
  /**
156
206
  * Substitute `{placeholder}` tokens in an argv template. Returns undefined when
157
207
  * the template needs a value this post does not have — the sink is then skipped
@@ -159,8 +209,13 @@ export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): stri
159
209
  * would otherwise comment on nothing.
160
210
  */
161
211
  export declare function renderSinkArgv(template: string[], ctx: FeedBroadcastContext): string[] | undefined;
162
- /** Render one channel-message template with the same fail-closed placeholder contract as argv. */
163
- export declare function renderSinkMessage(template: string, ctx: FeedBroadcastContext): string | undefined;
212
+ /**
213
+ * Render one channel-message template with the same fail-closed placeholder
214
+ * contract as argv. `format` (Slack `mrkdwn` vs `plain`) flows into the shared
215
+ * `{message}` var so a Slack sink gets labeled links and an iMessage/owner sink
216
+ * gets the plain sentence.
217
+ */
218
+ export declare function renderSinkMessage(template: string, ctx: FeedBroadcastContext, format?: SinkMessageFormat): string | undefined;
164
219
  /**
165
220
  * Which sinks this post reaches, in config order. Pure — the dry-run listing and
166
221
  * the real fan-out plan through here, so what `--dry-run` shows is what runs.
@@ -168,8 +223,12 @@ export declare function renderSinkMessage(template: string, ctx: FeedBroadcastCo
168
223
  * A `channel:` sink is gated by the same `minLevel` rule as a `command:` sink —
169
224
  * one level check for both shapes, so a dry-run plan is truthful regardless of
170
225
  * which shape an operator's sink uses.
226
+ *
227
+ * `meta` is used only to resolve a channel name to its real provider for the
228
+ * mrkdwn/plain format decision (`notify.transports`), the same map delivery uses;
229
+ * it is optional so a test can plan without a config snapshot (identity mapping).
171
230
  */
172
- export declare function planFeedBroadcast(config: FeedBroadcastConfig | undefined, ctx: FeedBroadcastContext): PlannedSink[];
231
+ export declare function planFeedBroadcast(config: FeedBroadcastConfig | undefined, ctx: FeedBroadcastContext, meta?: Meta): PlannedSink[];
173
232
  /**
174
233
  * The effective sink config for a post: the operator's `feed.broadcast`, or —
175
234
  * when that is unset or empty — an implicit fallback straight to
@@ -38,7 +38,8 @@ import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } fro
38
38
  import { lookupTransport } from './channels/resolve.js';
39
39
  import { registerBuiltinProviders } from './channels/providers/index.js';
40
40
  import { sendToOwner } from './notify.js';
41
- import { linearIssueUrl } from './session/linear.js';
41
+ import { linearIssueUrl, linearIssueKeys } from './session/linear.js';
42
+ import { isValidMailboxId } from './mailbox.js';
42
43
  import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
43
44
  const LEVEL_RANK = { milestone: 0, important: 1 };
44
45
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
@@ -145,6 +146,27 @@ export function shortSessionChunk(session) {
145
146
  const chunk = hex.replace(/[^a-f0-9]/g, '').slice(0, 8);
146
147
  return chunk || undefined;
147
148
  }
149
+ /**
150
+ * Tap-to-view link for the session behind a post: the addressable console page
151
+ * ({@link https://prix.dev/console/sessions/<id>}, prix/web). The footer already
152
+ * carries a short session crumb for disambiguation; this rides the link trail so
153
+ * the owner can open the full transcript straight from an iMessage broadcast
154
+ * instead of hunting for it in the console.
155
+ *
156
+ * Accepts any real, path-safe session id — a Claude/Codex UUID *and* a native
157
+ * `ses_…` id from OpenCode or another harness. The console shard uploader
158
+ * (`traces/sync.ts`) syncs sessions with no harness filter, so all of them are
159
+ * addressable; a UUID-only gate would silently drop the link for every non-Claude
160
+ * harness (the whole point of the link). Reject only an id that could not resolve:
161
+ * one with a path separator (URL-unsafe, via {@link isValidMailboxId}) or the bare
162
+ * 8-char footer crumb (a truncated id that would 404).
163
+ */
164
+ export function sessionConsoleUrl(session) {
165
+ const id = session?.trim();
166
+ if (!id || !isValidMailboxId(id) || /^[0-9a-f]{8}$/i.test(id))
167
+ return undefined;
168
+ return `https://prix.dev/console/sessions/${id}`;
169
+ }
148
170
  /**
149
171
  * Scrub em/en dashes from outbound phone copy (house rule + iMessage readability).
150
172
  * Collapses whitespace; does not invent meaning.
@@ -158,6 +180,56 @@ export function scrubOutboundDashes(text) {
158
180
  .replace(/[ \t]{2,}/g, ' ')
159
181
  .trim();
160
182
  }
183
+ /** Slack mrkdwn labeled link: `<url|label>` renders as blue `label` text. */
184
+ function slackLink(url, label) {
185
+ return `<${url}|${label}>`;
186
+ }
187
+ /**
188
+ * Only Slack renders `<url|label>`, so it is the one format that gets labeled
189
+ * links. iMessage / owner-scoped rush / command / desktop sinks stay `plain`
190
+ * (they can't turn `claude/6fc1db18` blue, and dumping the raw URL is worse than
191
+ * leaving the crumb unlinked — PHNX-3698).
192
+ *
193
+ * The argument is the **resolved provider name**, not the sink's declared
194
+ * channel: an operator can point an arbitrary channel name at the Slack provider
195
+ * through `notify.transports` (e.g. `eng-alerts -> slack`), and delivery keys off
196
+ * that resolved provider (`lookupTransport`), so the format decision must too —
197
+ * otherwise an aliased Slack sink would compose plain while delivering to Slack,
198
+ * or a name remapped AWAY from Slack would emit `<url|label>` markup a non-Slack
199
+ * transport shows literally. {@link resolveSinkProvider} does the mapping.
200
+ */
201
+ export function sinkMessageFormat(provider) {
202
+ return provider?.trim().toLowerCase() === 'slack' ? 'mrkdwn' : 'plain';
203
+ }
204
+ /**
205
+ * The provider a channel name actually delivers through — the same
206
+ * `notify.transports` remap `lookupTransport` applies at delivery — so the format
207
+ * decision and the delivery agree on what Slack is. Identity when no mapping
208
+ * exists (or no `meta`), matching the default name-identity transport rule.
209
+ */
210
+ function resolveSinkProvider(channel, meta) {
211
+ return meta?.notify?.transports?.[channel] ?? channel;
212
+ }
213
+ /**
214
+ * Replace each real Linear key the text NAMES with a Slack labeled link to its
215
+ * issue — `PHNX-3689` → `<https://linear.app/getrush/issue/PHNX-3689|PHNX-3689>`
216
+ * — so the key itself turns blue in place (no trailing URL line). Plain format,
217
+ * or a key the workspace can't resolve, or a denylisted unit string, is left as
218
+ * the bare key. `linearIssueKeys` is the same canonical detector the trail used,
219
+ * so mrkdwn linkifies exactly the keys plain leaves as text.
220
+ */
221
+ function linkifyKeys(text, format) {
222
+ if (format !== 'mrkdwn' || !text)
223
+ return text;
224
+ let out = text;
225
+ for (const key of linearIssueKeys(text)) {
226
+ const url = linearIssueUrl(key);
227
+ if (!url)
228
+ continue;
229
+ out = out.replace(new RegExp(`\\b${key}\\b`, 'g'), slackLink(url, key));
230
+ }
231
+ return out;
232
+ }
161
233
  /**
162
234
  * Footer like "Sent from my iPhone" — who posted, a session crumb, which box.
163
235
  *
@@ -165,8 +237,13 @@ export function scrubOutboundDashes(text) {
165
237
  *
166
238
  * Agent name first; session chunk for disambiguation when many groks run;
167
239
  * host last. Skip the uninformative default label `agent`.
240
+ *
241
+ * In `mrkdwn` the crumb (`agent/short`) becomes a Slack labeled link to the
242
+ * session's console page, so the human sentence reads identically while the
243
+ * crumb turns blue and taps through (PHNX-3698). `plain` keeps the bare sentence
244
+ * — it can't render a labeled link and must not dump the URL.
168
245
  */
169
- export function composeBroadcastFooter(ctx) {
246
+ export function composeBroadcastFooter(ctx, format = 'plain') {
170
247
  const agent = ctx.agent?.trim();
171
248
  const agentLabel = agent && agent !== 'agent' ? agent : undefined;
172
249
  const session = shortSessionChunk(ctx.session);
@@ -178,6 +255,10 @@ export function composeBroadcastFooter(ctx) {
178
255
  who = agentLabel;
179
256
  else if (session)
180
257
  who = session;
258
+ // The crumb is only a link when the session resolves to a real console page.
259
+ const consoleUrl = who ? sessionConsoleUrl(ctx.session) : undefined;
260
+ if (who && format === 'mrkdwn' && consoleUrl)
261
+ who = slackLink(consoleUrl, who);
181
262
  if (who && host)
182
263
  return `Sent from ${who} on ${host}`;
183
264
  if (who)
@@ -236,16 +317,17 @@ export function truncateBroadcastBody(body) {
236
317
  return body;
237
318
  return `${out.trimEnd()}\n… (full in feed)`;
238
319
  }
239
- export function composeBroadcastMessage(ctx) {
320
+ export function composeBroadcastMessage(ctx, format = 'plain') {
240
321
  const title = scrubOutboundDashes(ctx.title ?? '');
241
322
  const body = truncateBroadcastBody(scrubOutboundDashes(ctx.text ?? ''));
242
323
  // Title preferred; if an older post has no title, body alone still sends.
243
- const head = title || body;
244
- const mid = title && body && title !== body ? body : undefined;
245
- const footer = composeBroadcastFooter(ctx);
246
- const links = [ctx.ticketUrl, ...(ctx.links ?? [])]
247
- .filter((l) => !!l && /^https?:\/\//i.test(l))
248
- .filter((l, i, all) => all.indexOf(l) === i);
324
+ // A `TEAM-N` key the human typed is dead text on a phone — in `mrkdwn` the key
325
+ // itself becomes a Slack labeled link in place, so nothing rides a trailing
326
+ // naked URL line; `plain` leaves the bare key (iMessage can't render a label,
327
+ // and dumping the URL is worse than leaving it — PHNX-3698).
328
+ const head = linkifyKeys(title || body, format);
329
+ const mid = title && body && title !== body ? linkifyKeys(body, format) : undefined;
330
+ const footer = composeBroadcastFooter(ctx, format);
249
331
  // The action block: the one thing the operator can act on from a phone. Show the
250
332
  // choices, then what happens if they do not answer. Deliberately NOT a CLI command
251
333
  // (`agents focus <id>` is unusable from a phone) -- the safe default is the real
@@ -259,8 +341,6 @@ export function composeBroadcastMessage(ctx) {
259
341
  : `Default: ${scrubOutboundDashes(ctx.safeDefault)}`)
260
342
  : undefined;
261
343
  const action = [choices, fallback].filter(Boolean).join('\n') || undefined;
262
- // Link trail after the "Sent from" footer so the human sentence stays at the top.
263
- const trail = [footer, ...links].filter(Boolean);
264
344
  const parts = [];
265
345
  if (head)
266
346
  parts.push(head);
@@ -275,16 +355,22 @@ export function composeBroadcastMessage(ctx) {
275
355
  parts.push('');
276
356
  parts.push(action);
277
357
  }
278
- if (trail.length) {
279
- // Blank line before the footer block (iPhone "Sent from my iPhone" spacing).
358
+ if (footer) {
359
+ // Blank line before the "Sent from" footer (iPhone "Sent from my iPhone" spacing).
360
+ // The crumb/ticket links are inline (footer + prose) — never a trailing URL line.
280
361
  if (parts.length)
281
362
  parts.push('');
282
- parts.push(trail.join('\n'));
363
+ parts.push(footer);
283
364
  }
284
365
  return parts.join('\n').trim();
285
366
  }
286
- /** The values a template may reference, resolved once per post. */
287
- function templateVars(ctx) {
367
+ /**
368
+ * The values a template may reference, resolved once per post. `format` decides
369
+ * how `{message}` surfaces its links — Slack `mrkdwn` (labeled links) vs `plain`
370
+ * (the human sentence, no URLs). The scalar `{ticket_url}`/`{links}` vars are the
371
+ * raw URLs a custom `message:` template can place itself, so they are unaffected.
372
+ */
373
+ function templateVars(ctx, format = 'plain') {
288
374
  return {
289
375
  title: ctx.title,
290
376
  text: ctx.text,
@@ -296,7 +382,7 @@ function templateVars(ctx) {
296
382
  session: ctx.session,
297
383
  level: ctx.level,
298
384
  links: ctx.links?.length ? ctx.links.join(' ') : undefined,
299
- message: composeBroadcastMessage(ctx),
385
+ message: composeBroadcastMessage(ctx, format),
300
386
  block: ctx.blockId,
301
387
  class: ctx.class,
302
388
  cost: ctx.cost,
@@ -330,9 +416,14 @@ export function renderSinkArgv(template, ctx) {
330
416
  }
331
417
  return argv.length > 0 ? argv : undefined;
332
418
  }
333
- /** Render one channel-message template with the same fail-closed placeholder contract as argv. */
334
- export function renderSinkMessage(template, ctx) {
335
- const vars = templateVars(ctx);
419
+ /**
420
+ * Render one channel-message template with the same fail-closed placeholder
421
+ * contract as argv. `format` (Slack `mrkdwn` vs `plain`) flows into the shared
422
+ * `{message}` var so a Slack sink gets labeled links and an iMessage/owner sink
423
+ * gets the plain sentence.
424
+ */
425
+ export function renderSinkMessage(template, ctx, format = 'plain') {
426
+ const vars = templateVars(ctx, format);
336
427
  let missing = false;
337
428
  const rendered = template.replace(PLACEHOLDER, (whole, key) => {
338
429
  const value = vars[key];
@@ -367,8 +458,12 @@ export function renderSinkMessage(template, ctx) {
367
458
  * A `channel:` sink is gated by the same `minLevel` rule as a `command:` sink —
368
459
  * one level check for both shapes, so a dry-run plan is truthful regardless of
369
460
  * which shape an operator's sink uses.
461
+ *
462
+ * `meta` is used only to resolve a channel name to its real provider for the
463
+ * mrkdwn/plain format decision (`notify.transports`), the same map delivery uses;
464
+ * it is optional so a test can plan without a config snapshot (identity mapping).
370
465
  */
371
- export function planFeedBroadcast(config, ctx) {
466
+ export function planFeedBroadcast(config, ctx, meta) {
372
467
  if (!config)
373
468
  return [];
374
469
  const planned = [];
@@ -386,7 +481,14 @@ export function planFeedBroadcast(config, ctx) {
386
481
  // placeholder below).
387
482
  if (!isOwnerAlias(channel) && !sink.to?.trim())
388
483
  continue;
389
- const text = renderSinkMessage(sink.message ?? '{message}', ctx);
484
+ // Slack renders labeled links; every other channel (owner alias, iMessage,
485
+ // telegram, discord, mailbox, desktop) stays plain (PHNX-3698). The owner
486
+ // alias is deliberately plain even when it fans out to a Slack destination:
487
+ // it delivers ONE shared string to every channel in owner.policy.normal, so
488
+ // mrkdwn markup would corrupt a sibling iMessage copy. Keying on the
489
+ // resolved provider (not the raw name) matches what delivery does.
490
+ const provider = isOwnerAlias(channel) ? channel : resolveSinkProvider(channel, meta);
491
+ const text = renderSinkMessage(sink.message ?? '{message}', ctx, sinkMessageFormat(provider));
390
492
  if (!text)
391
493
  continue;
392
494
  planned.push({
@@ -54,6 +54,14 @@ export function decideFire(monitor, observation) {
54
54
  const raw = observation.raw;
55
55
  const payload = observation.meta ?? {};
56
56
  const dedupeKey = cond.dedupeKey;
57
+ // A snapshot the source flagged as an OBSERVATION FAILURE (a poll that exited
58
+ // non-zero or emitted a transport/auth/rate-limit error) is never a value
59
+ // change: don't fire, don't move the baseline — so an empty→error→empty flap
60
+ // can't read as two value changes (PHNX-3510). The engine records it as a
61
+ // failed check separately, feeding the drought health streak.
62
+ if (observation.failed) {
63
+ return { fire: false, value: raw, dedupeKey, persist: false, event: null };
64
+ }
57
65
  if (cond.mode === 'every') {
58
66
  // Fire on every tick that carries a real observation. An empty (or
59
67
  // whitespace-only) observation means "nothing to report": firing an action
@@ -203,6 +211,16 @@ export class MonitorEngine {
203
211
  if (!observation) {
204
212
  checkError = 'source produced no observation';
205
213
  }
214
+ else if (observation.failed) {
215
+ // The poll ran but did not OBSERVE (non-zero exit, or a transport/auth/
216
+ // rate-limit error in its output). Skip it entirely: no decideFire, no
217
+ // fire, watched-state untouched — so no empty→error→empty flap dispatches
218
+ // an agent on a dead premise. Record it as a failed check so a sustained
219
+ // streak escalates as a drought, the same health surface `--postcondition`
220
+ // uses on the action side (PHNX-3510).
221
+ checkError = `poll failed: ${observation.failureReason ?? 'observation failure'}`;
222
+ this.logFn('WARN', `monitor '${monitor.name}' poll failed (${observation.failureReason ?? 'observation failure'}) — not treated as a value change`);
223
+ }
206
224
  else {
207
225
  const decision = decideFire(monitor, observation);
208
226
  if (decision.fire && decision.event) {
@@ -6,6 +6,7 @@
6
6
  * here. No agent, no sandbox: a plain `/bin/sh -c` (or `cmd /c` on Windows).
7
7
  */
8
8
  import { execFile } from 'child_process';
9
+ import { classifyPollFailure } from './failure.js';
9
10
  const DEFAULT_TIMEOUT_MS = 60_000;
10
11
  /** Run the source command and return its combined stdout as the observation. */
11
12
  export function evaluate(source) {
@@ -22,10 +23,19 @@ export function evaluate(source) {
22
23
  : err
23
24
  ? 1
24
25
  : 0;
25
- // A non-zero exit is still a real observation (the diff might be exactly
26
- // "command started failing"); surface stderr when stdout is empty.
26
+ // Surface stderr when stdout is empty.
27
27
  const raw = (stdout && stdout.length > 0 ? stdout : stderr ?? '').replace(/\s+$/, '');
28
- resolve({ raw, meta: { exitCode } });
28
+ // A poll that failed to OBSERVE — non-zero exit, or a transport/auth/
29
+ // rate-limit error shape in its output (which a piped `gh … | jq`
30
+ // swallows the exit code of) — is not a new value. Flag it so the engine
31
+ // skips it instead of reading empty→error→empty as two value changes and
32
+ // dispatching an agent on a dead premise (PHNX-3510).
33
+ const failureReason = classifyPollFailure({ exitCode, text: raw });
34
+ resolve({
35
+ raw,
36
+ meta: { exitCode },
37
+ ...(failureReason ? { failed: true, failureReason } : {}),
38
+ });
29
39
  });
30
40
  });
31
41
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Poll-failure classifier (PHNX-3510).
3
+ *
4
+ * A poll that FAILS to observe — the command exited non-zero, or its output
5
+ * carries a transport/auth/rate-limit error shape — is an OBSERVATION FAILURE,
6
+ * not a new value. Reading it as a value is the defect this closes: a
7
+ * `gh pr list … | jq` monitor whose gh half intermittently prints
8
+ * `GraphQL: API rate limit already exceeded …` flapped empty→error→empty, and an
9
+ * `[on-change]` monitor read that as two value changes and dispatched a full
10
+ * agent run on a premise that was false.
11
+ *
12
+ * The exit code alone is not enough: when gh is piped into jq the shell's exit
13
+ * status is jq's (0 on empty input), so the rate-limit text can ride an exit-0
14
+ * observation. The text patterns catch exactly that case. They stay tightly
15
+ * scoped to unambiguous failure shapes so a legitimate observation whose content
16
+ * merely mentions "timeout" is never misread as a failure.
17
+ */
18
+ /** The failure reason matched in a poll's output text, or null when it looks clean. */
19
+ export declare function matchFailureText(text: string): string | null;
20
+ /**
21
+ * Classify one poll snapshot. Returns a short failure reason when the snapshot is
22
+ * an observation failure (non-zero exit, or a failure-shaped output), else null —
23
+ * in which case the snapshot is a genuine value the condition may diff.
24
+ *
25
+ * A failure-shaped OUTPUT is checked even on exit 0, because a piped command
26
+ * (`gh … | jq`) swallows the failing half's exit code. A non-zero exit is a
27
+ * failure regardless of output shape.
28
+ */
29
+ export declare function classifyPollFailure(input: {
30
+ exitCode?: number;
31
+ text: string;
32
+ }): string | null;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Poll-failure classifier (PHNX-3510).
3
+ *
4
+ * A poll that FAILS to observe — the command exited non-zero, or its output
5
+ * carries a transport/auth/rate-limit error shape — is an OBSERVATION FAILURE,
6
+ * not a new value. Reading it as a value is the defect this closes: a
7
+ * `gh pr list … | jq` monitor whose gh half intermittently prints
8
+ * `GraphQL: API rate limit already exceeded …` flapped empty→error→empty, and an
9
+ * `[on-change]` monitor read that as two value changes and dispatched a full
10
+ * agent run on a premise that was false.
11
+ *
12
+ * The exit code alone is not enough: when gh is piped into jq the shell's exit
13
+ * status is jq's (0 on empty input), so the rate-limit text can ride an exit-0
14
+ * observation. The text patterns catch exactly that case. They stay tightly
15
+ * scoped to unambiguous failure shapes so a legitimate observation whose content
16
+ * merely mentions "timeout" is never misread as a failure.
17
+ */
18
+ const FAILURE_TEXT_PATTERNS = [
19
+ { re: /\bAPI rate limit (?:already )?exceeded\b/i, reason: 'API rate limit exceeded' },
20
+ { re: /\bsecondary rate limit\b/i, reason: 'secondary rate limit' },
21
+ { re: /^\s*GraphQL:\s/im, reason: 'GraphQL error' },
22
+ { re: /\bbad credentials\b/i, reason: 'bad credentials' },
23
+ { re: /\b(?:401 Unauthorized|403 Forbidden)\b/i, reason: 'auth error' },
24
+ { re: /\bcould not resolve host\b/i, reason: 'transport error (DNS)' },
25
+ { re: /\bconnection (?:refused|reset|timed out)\b/i, reason: 'connection error' },
26
+ { re: /\bnetwork is unreachable\b/i, reason: 'network unreachable' },
27
+ ];
28
+ /** The failure reason matched in a poll's output text, or null when it looks clean. */
29
+ export function matchFailureText(text) {
30
+ for (const { re, reason } of FAILURE_TEXT_PATTERNS) {
31
+ if (re.test(text))
32
+ return reason;
33
+ }
34
+ return null;
35
+ }
36
+ /**
37
+ * Classify one poll snapshot. Returns a short failure reason when the snapshot is
38
+ * an observation failure (non-zero exit, or a failure-shaped output), else null —
39
+ * in which case the snapshot is a genuine value the condition may diff.
40
+ *
41
+ * A failure-shaped OUTPUT is checked even on exit 0, because a piped command
42
+ * (`gh … | jq`) swallows the failing half's exit code. A non-zero exit is a
43
+ * failure regardless of output shape.
44
+ */
45
+ export function classifyPollFailure(input) {
46
+ const textReason = matchFailureText(input.text);
47
+ const badExit = typeof input.exitCode === 'number' && input.exitCode !== 0;
48
+ if (badExit) {
49
+ return textReason ? `${textReason} (exit ${input.exitCode})` : `command exited ${input.exitCode}`;
50
+ }
51
+ return textReason;
52
+ }
@@ -11,6 +11,15 @@ import type { MonitorSource } from '../config.js';
11
11
  export interface Observation {
12
12
  raw: string;
13
13
  meta?: Record<string, unknown>;
14
+ /**
15
+ * The source flagged this snapshot as an OBSERVATION FAILURE (a poll that
16
+ * exited non-zero or emitted a transport/auth/rate-limit error), not a value.
17
+ * The engine skips it: no fire, watched-state untouched, counted as a failed
18
+ * check for drought health (PHNX-3510).
19
+ */
20
+ failed?: boolean;
21
+ /** Short human reason for `failed`, surfaced in drought health and `test`. */
22
+ failureReason?: string;
14
23
  }
15
24
  /** Poll-model evaluator: return one observation, or null when none is available. */
16
25
  export type SourceEvaluator = (source: MonitorSource) => Promise<Observation | null>;
@@ -0,0 +1,12 @@
1
+ export interface OwnerMessageOptions {
2
+ /** Scannable subject line, when the caller has one (feed post does; notify does not). */
3
+ title?: string;
4
+ /** Explicit session id (`--session`); otherwise resolved from the run environment. */
5
+ sessionId?: string;
6
+ }
7
+ /**
8
+ * Shape a raw owner-send body into the composed broadcast message. Pure except
9
+ * for the identity/index reads that {@link resolvePostIdentity} /
10
+ * {@link getSessionById} already perform for feed posts.
11
+ */
12
+ export declare function composeOwnerMessage(rawText: string, opts?: OwnerMessageOptions): string;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Compose an owner-bound phone ping through the SAME shaper `agents feed post`
3
+ * uses, so `agents notify` / `agents send --to owner` stop shipping a raw body
4
+ * dump (PHNX-3698).
5
+ *
6
+ * Before this, an owner send delivered the body verbatim: a long wall of prose,
7
+ * a `TEAM-N` key iMessage renders as dead text, and no way back to the session.
8
+ * Routing the body through {@link composeBroadcastMessage} makes an owner ping
9
+ * identical to an important feed post of the same event — short-shaped body,
10
+ * every ticket key linkified to its Linear URL, and the current session's
11
+ * `…/console/sessions/<id>` page as a tappable crumb.
12
+ *
13
+ * The session/agent/host are resolved the same way a feed post resolves them
14
+ * ({@link resolvePostIdentity} — the pid-registry / env walk), so a `notify`
15
+ * run inside an agent session inherits that session with no flag. Outside a
16
+ * session (a human at a shell) identity is undefined: the ping still gets
17
+ * short-shaping and ticket linkification, just no session crumb.
18
+ */
19
+ import { composeBroadcastMessage } from './feed-broadcast.js';
20
+ import { resolvePostIdentity } from './feed-post.js';
21
+ import { getSessionById, resolveFullSessionId } from './session/db.js';
22
+ import { linearIssueUrl } from './session/linear.js';
23
+ /**
24
+ * Shape a raw owner-send body into the composed broadcast message. Pure except
25
+ * for the identity/index reads that {@link resolvePostIdentity} /
26
+ * {@link getSessionById} already perform for feed posts.
27
+ */
28
+ export function composeOwnerMessage(rawText, opts = {}) {
29
+ const identity = resolvePostIdentity({ sessionId: opts.sessionId });
30
+ // A footer crumb that would 404 (an 8-char short id) is upgraded to the full
31
+ // indexed id so the console URL resolves; a full/native id passes through.
32
+ const session = resolveFullSessionId(identity?.sessionId);
33
+ const ticket = session ? getSessionById(session)?.ticketId : undefined;
34
+ const ctx = {
35
+ ...(opts.title?.trim() ? { title: opts.title.trim() } : {}),
36
+ text: rawText,
37
+ level: 'important',
38
+ ...(ticket ? { ticket, ticketUrl: linearIssueUrl(ticket) } : {}),
39
+ ...(identity?.agent ? { agent: identity.agent } : {}),
40
+ ...(identity?.host ? { host: identity.host } : {}),
41
+ ...(session ? { session } : {}),
42
+ };
43
+ return composeBroadcastMessage(ctx);
44
+ }
@@ -7,7 +7,22 @@ export declare function shouldAutoSyncTraces(disabled: boolean): boolean;
7
7
  * Arm a fire-and-forget `agents traces sync` for when this process exits.
8
8
  * No-op unless {@link shouldAutoSyncTraces} passes. Best-effort by
9
9
  * construction: a missing binary or a stalled child never affects the run.
10
+ * The spawn happens in the exit handler because the upload is async work Node
11
+ * cannot run after `exit` — a watchdog here could never fire.
10
12
  */
11
13
  export declare function armRunFinishTraceSync(opts?: {
12
14
  disabled?: boolean;
13
15
  }): void;
16
+ /**
17
+ * Fire a fire-and-forget `agents traces sync` NOW (not on exit). An important
18
+ * owner-bound ping (`feed post --level important`, `agents notify`,
19
+ * `send --to owner`) links the caller's `…/console/sessions/<id>` page, and that
20
+ * page only exists once the session's shard has been uploaded — trace sync fires
21
+ * on run exit (PHNX-3628), not when a mid-run ping is posted. This closes that
22
+ * gap so the tapped link resolves instead of 404ing. No-op unless
23
+ * {@link shouldAutoSyncTraces} passes (signed in + already opted into the store);
24
+ * the incremental watermark keeps the push to essentially just this session.
25
+ */
26
+ export declare function fireTraceSyncInBackground(opts?: {
27
+ disabled?: boolean;
28
+ }): void;