@phnx-labs/agents-cli 1.22.68 → 1.22.70

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 (132) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +17 -14
  3. package/dist/bootstrap.js +3 -0
  4. package/dist/commands/accounts.js +23 -7
  5. package/dist/commands/exec.js +33 -23
  6. package/dist/commands/feed.js +20 -7
  7. package/dist/commands/import.js +2 -2
  8. package/dist/commands/models.js +2 -2
  9. package/dist/commands/monitors.js +3 -0
  10. package/dist/commands/permissions.js +2 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/rules.js +1 -1
  14. package/dist/commands/send.js +35 -2
  15. package/dist/commands/sessions-inject.d.ts +58 -0
  16. package/dist/commands/sessions-inject.js +143 -7
  17. package/dist/commands/sessions-picker.js +1 -0
  18. package/dist/commands/share.js +43 -14
  19. package/dist/commands/ssh.js +205 -2
  20. package/dist/commands/traces.js +1 -1
  21. package/dist/lib/account-capabilities.js +0 -2
  22. package/dist/lib/account-registry.d.ts +3 -3
  23. package/dist/lib/account-registry.js +25 -7
  24. package/dist/lib/accounting/usage.d.ts +7 -2
  25. package/dist/lib/accounting/usage.js +142 -10
  26. package/dist/lib/acp/client.d.ts +1 -1
  27. package/dist/lib/acp/client.js +12 -1
  28. package/dist/lib/acp/harnesses.js +1 -1
  29. package/dist/lib/add-dir.js +0 -2
  30. package/dist/lib/agent-cli-commands.js +0 -2
  31. package/dist/lib/agent-spec/agents.d.ts +1 -1
  32. package/dist/lib/agent-spec/agents.js +2 -83
  33. package/dist/lib/boot-profile.d.ts +14 -0
  34. package/dist/lib/boot-profile.js +66 -0
  35. package/dist/lib/browser/ipc.js +9 -1
  36. package/dist/lib/browser/remote-control.d.ts +6 -3
  37. package/dist/lib/browser/remote-control.js +6 -3
  38. package/dist/lib/browser/service.d.ts +4 -1
  39. package/dist/lib/browser/service.js +7 -1
  40. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  41. package/dist/lib/channels/providers/desktop.js +5 -4
  42. package/dist/lib/channels/registry.d.ts +6 -0
  43. package/dist/lib/channels/send.d.ts +11 -2
  44. package/dist/lib/channels/send.js +11 -2
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/cloud/rush.d.ts +10 -2
  47. package/dist/lib/cloud/rush.js +15 -9
  48. package/dist/lib/daemon/daemon.js +28 -8
  49. package/dist/lib/daemon/runner.js +6 -2
  50. package/dist/lib/devices/health.d.ts +38 -2
  51. package/dist/lib/devices/health.js +43 -5
  52. package/dist/lib/devices/worker-pick.d.ts +1 -1
  53. package/dist/lib/devices/worker-pick.js +4 -1
  54. package/dist/lib/exec.d.ts +2 -2
  55. package/dist/lib/exec.js +7 -34
  56. package/dist/lib/feed-broadcast.d.ts +47 -5
  57. package/dist/lib/feed-broadcast.js +127 -24
  58. package/dist/lib/hooks/install.js +0 -87
  59. package/dist/lib/installations/strategies.js +1 -1
  60. package/dist/lib/mcp-registry.js +0 -13
  61. package/dist/lib/mcp.js +2 -2
  62. package/dist/lib/model-tiers.js +1 -1
  63. package/dist/lib/models.js +0 -63
  64. package/dist/lib/monitors/engine.js +18 -0
  65. package/dist/lib/monitors/sources/command.js +13 -3
  66. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  67. package/dist/lib/monitors/sources/failure.js +52 -0
  68. package/dist/lib/monitors/sources/types.d.ts +9 -0
  69. package/dist/lib/notify.d.ts +11 -0
  70. package/dist/lib/notify.js +17 -4
  71. package/dist/lib/owner-message.d.ts +55 -0
  72. package/dist/lib/owner-message.js +64 -0
  73. package/dist/lib/permissions-registry.d.ts +0 -2
  74. package/dist/lib/permissions-registry.js +3 -50
  75. package/dist/lib/permissions.d.ts +3 -17
  76. package/dist/lib/permissions.js +4 -73
  77. package/dist/lib/run-trace-sync.d.ts +15 -0
  78. package/dist/lib/run-trace-sync.js +43 -21
  79. package/dist/lib/rush-session.d.ts +19 -0
  80. package/dist/lib/rush-session.js +24 -0
  81. package/dist/lib/secrets/drivers/rush.js +2 -1
  82. package/dist/lib/secrets/filestore.d.ts +4 -0
  83. package/dist/lib/secrets/filestore.js +164 -3
  84. package/dist/lib/session/active.d.ts +10 -0
  85. package/dist/lib/session/active.js +3 -0
  86. package/dist/lib/session/cloud.js +2 -1
  87. package/dist/lib/session/db.d.ts +12 -1
  88. package/dist/lib/session/db.js +20 -1
  89. package/dist/lib/session/discover.js +81 -1
  90. package/dist/lib/session/linear.d.ts +13 -0
  91. package/dist/lib/session/linear.js +44 -0
  92. package/dist/lib/session/live-metadata.js +1 -0
  93. package/dist/lib/session/parse.js +2 -3
  94. package/dist/lib/session/prompt.d.ts +7 -1
  95. package/dist/lib/session/prompt.js +12 -2
  96. package/dist/lib/session/recovery.d.ts +21 -12
  97. package/dist/lib/session/recovery.js +29 -11
  98. package/dist/lib/session/remote/watch.js +5 -2
  99. package/dist/lib/session/state.js +11 -13
  100. package/dist/lib/share/backend.d.ts +2 -2
  101. package/dist/lib/share/backend.js +20 -9
  102. package/dist/lib/share/delete.d.ts +5 -1
  103. package/dist/lib/share/delete.js +7 -2
  104. package/dist/lib/share/http-error.d.ts +52 -0
  105. package/dist/lib/share/http-error.js +65 -0
  106. package/dist/lib/share/publish.d.ts +13 -3
  107. package/dist/lib/share/publish.js +19 -15
  108. package/dist/lib/share/worker-template.js +5 -1
  109. package/dist/lib/sink-format.d.ts +34 -0
  110. package/dist/lib/sink-format.js +17 -0
  111. package/dist/lib/smart-launch.js +27 -4
  112. package/dist/lib/staleness/detectors/permissions.js +0 -20
  113. package/dist/lib/staleness/writers/commands.js +1 -1
  114. package/dist/lib/staleness/writers/hooks.js +2 -2
  115. package/dist/lib/storage/index.d.ts +14 -0
  116. package/dist/lib/storage/index.js +14 -0
  117. package/dist/lib/storage/selection.d.ts +48 -0
  118. package/dist/lib/storage/selection.js +39 -0
  119. package/dist/lib/storage/visibility.d.ts +82 -0
  120. package/dist/lib/storage/visibility.js +99 -0
  121. package/dist/lib/subagents-registry.js +2 -12
  122. package/dist/lib/subagents.d.ts +0 -10
  123. package/dist/lib/subagents.js +0 -37
  124. package/dist/lib/teams/agents.js +3 -1
  125. package/dist/lib/teams/placement-probe.js +1 -0
  126. package/dist/lib/teams/scheduler.d.ts +8 -1
  127. package/dist/lib/teams/scheduler.js +4 -1
  128. package/dist/lib/traces/backend.js +13 -2
  129. package/dist/lib/types.d.ts +1 -1
  130. package/dist/lib/worktree/held.d.ts +166 -0
  131. package/dist/lib/worktree/held.js +368 -0
  132. package/package.json +2 -2
@@ -38,8 +38,10 @@ 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';
44
+ import { sinkMessageFormat } from './sink-format.js';
43
45
  const LEVEL_RANK = { milestone: 0, important: 1 };
44
46
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
45
47
  export function parseFeedPostLevel(raw) {
@@ -145,6 +147,27 @@ export function shortSessionChunk(session) {
145
147
  const chunk = hex.replace(/[^a-f0-9]/g, '').slice(0, 8);
146
148
  return chunk || undefined;
147
149
  }
150
+ /**
151
+ * Tap-to-view link for the session behind a post: the addressable console page
152
+ * ({@link https://prix.dev/console/sessions/<id>}, prix/web). The footer already
153
+ * carries a short session crumb for disambiguation; this rides the link trail so
154
+ * the owner can open the full transcript straight from an iMessage broadcast
155
+ * instead of hunting for it in the console.
156
+ *
157
+ * Accepts any real, path-safe session id — a Claude/Codex UUID *and* a native
158
+ * `ses_…` id from OpenCode or another harness. The console shard uploader
159
+ * (`traces/sync.ts`) syncs sessions with no harness filter, so all of them are
160
+ * addressable; a UUID-only gate would silently drop the link for every non-Claude
161
+ * harness (the whole point of the link). Reject only an id that could not resolve:
162
+ * one with a path separator (URL-unsafe, via {@link isValidMailboxId}) or the bare
163
+ * 8-char footer crumb (a truncated id that would 404).
164
+ */
165
+ export function sessionConsoleUrl(session) {
166
+ const id = session?.trim();
167
+ if (!id || !isValidMailboxId(id) || /^[0-9a-f]{8}$/i.test(id))
168
+ return undefined;
169
+ return `https://prix.dev/console/sessions/${id}`;
170
+ }
148
171
  /**
149
172
  * Scrub em/en dashes from outbound phone copy (house rule + iMessage readability).
150
173
  * Collapses whitespace; does not invent meaning.
@@ -158,6 +181,39 @@ export function scrubOutboundDashes(text) {
158
181
  .replace(/[ \t]{2,}/g, ' ')
159
182
  .trim();
160
183
  }
184
+ /** Slack mrkdwn labeled link: `<url|label>` renders as blue `label` text. */
185
+ function slackLink(url, label) {
186
+ return `<${url}|${label}>`;
187
+ }
188
+ /**
189
+ * The provider a channel name actually delivers through — the same
190
+ * `notify.transports` remap `lookupTransport` applies at delivery — so the format
191
+ * decision and the delivery agree on what Slack is. Identity when no mapping
192
+ * exists (or no `meta`), matching the default name-identity transport rule.
193
+ */
194
+ function resolveSinkProvider(channel, meta) {
195
+ return meta?.notify?.transports?.[channel] ?? channel;
196
+ }
197
+ /**
198
+ * Replace each real Linear key the text NAMES with a Slack labeled link to its
199
+ * issue — `PHNX-3689` → `<https://linear.app/getrush/issue/PHNX-3689|PHNX-3689>`
200
+ * — so the key itself turns blue in place (no trailing URL line). Plain format,
201
+ * or a key the workspace can't resolve, or a denylisted unit string, is left as
202
+ * the bare key. `linearIssueKeys` is the same canonical detector the trail used,
203
+ * so mrkdwn linkifies exactly the keys plain leaves as text.
204
+ */
205
+ function linkifyKeys(text, format) {
206
+ if (format !== 'mrkdwn' || !text)
207
+ return text;
208
+ let out = text;
209
+ for (const key of linearIssueKeys(text)) {
210
+ const url = linearIssueUrl(key);
211
+ if (!url)
212
+ continue;
213
+ out = out.replace(new RegExp(`\\b${key}\\b`, 'g'), slackLink(url, key));
214
+ }
215
+ return out;
216
+ }
161
217
  /**
162
218
  * Footer like "Sent from my iPhone" — who posted, a session crumb, which box.
163
219
  *
@@ -165,8 +221,13 @@ export function scrubOutboundDashes(text) {
165
221
  *
166
222
  * Agent name first; session chunk for disambiguation when many groks run;
167
223
  * host last. Skip the uninformative default label `agent`.
224
+ *
225
+ * In `mrkdwn` the crumb (`agent/short`) becomes a Slack labeled link to the
226
+ * session's console page, so the human sentence reads identically while the
227
+ * crumb turns blue and taps through (PHNX-3698). `plain` keeps the bare sentence
228
+ * — it can't render a labeled link and must not dump the URL.
168
229
  */
169
- export function composeBroadcastFooter(ctx) {
230
+ export function composeBroadcastFooter(ctx, format = 'plain') {
170
231
  const agent = ctx.agent?.trim();
171
232
  const agentLabel = agent && agent !== 'agent' ? agent : undefined;
172
233
  const session = shortSessionChunk(ctx.session);
@@ -178,6 +239,10 @@ export function composeBroadcastFooter(ctx) {
178
239
  who = agentLabel;
179
240
  else if (session)
180
241
  who = session;
242
+ // The crumb is only a link when the session resolves to a real console page.
243
+ const consoleUrl = who ? sessionConsoleUrl(ctx.session) : undefined;
244
+ if (who && format === 'mrkdwn' && consoleUrl)
245
+ who = slackLink(consoleUrl, who);
181
246
  if (who && host)
182
247
  return `Sent from ${who} on ${host}`;
183
248
  if (who)
@@ -236,16 +301,17 @@ export function truncateBroadcastBody(body) {
236
301
  return body;
237
302
  return `${out.trimEnd()}\n… (full in feed)`;
238
303
  }
239
- export function composeBroadcastMessage(ctx) {
304
+ export function composeBroadcastMessage(ctx, format = 'plain') {
240
305
  const title = scrubOutboundDashes(ctx.title ?? '');
241
306
  const body = truncateBroadcastBody(scrubOutboundDashes(ctx.text ?? ''));
242
307
  // 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);
308
+ // A `TEAM-N` key the human typed is dead text on a phone — in `mrkdwn` the key
309
+ // itself becomes a Slack labeled link in place, so nothing rides a trailing
310
+ // naked URL line; `plain` leaves the bare key (iMessage can't render a label,
311
+ // and dumping the URL is worse than leaving it — PHNX-3698).
312
+ const head = linkifyKeys(title || body, format);
313
+ const mid = title && body && title !== body ? linkifyKeys(body, format) : undefined;
314
+ const footer = composeBroadcastFooter(ctx, format);
249
315
  // The action block: the one thing the operator can act on from a phone. Show the
250
316
  // choices, then what happens if they do not answer. Deliberately NOT a CLI command
251
317
  // (`agents focus <id>` is unusable from a phone) -- the safe default is the real
@@ -259,8 +325,6 @@ export function composeBroadcastMessage(ctx) {
259
325
  : `Default: ${scrubOutboundDashes(ctx.safeDefault)}`)
260
326
  : undefined;
261
327
  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
328
  const parts = [];
265
329
  if (head)
266
330
  parts.push(head);
@@ -275,16 +339,22 @@ export function composeBroadcastMessage(ctx) {
275
339
  parts.push('');
276
340
  parts.push(action);
277
341
  }
278
- if (trail.length) {
279
- // Blank line before the footer block (iPhone "Sent from my iPhone" spacing).
342
+ if (footer) {
343
+ // Blank line before the "Sent from" footer (iPhone "Sent from my iPhone" spacing).
344
+ // The crumb/ticket links are inline (footer + prose) — never a trailing URL line.
280
345
  if (parts.length)
281
346
  parts.push('');
282
- parts.push(trail.join('\n'));
347
+ parts.push(footer);
283
348
  }
284
349
  return parts.join('\n').trim();
285
350
  }
286
- /** The values a template may reference, resolved once per post. */
287
- function templateVars(ctx) {
351
+ /**
352
+ * The values a template may reference, resolved once per post. `format` decides
353
+ * how `{message}` surfaces its links — Slack `mrkdwn` (labeled links) vs `plain`
354
+ * (the human sentence, no URLs). The scalar `{ticket_url}`/`{links}` vars are the
355
+ * raw URLs a custom `message:` template can place itself, so they are unaffected.
356
+ */
357
+ function templateVars(ctx, format = 'plain') {
288
358
  return {
289
359
  title: ctx.title,
290
360
  text: ctx.text,
@@ -296,7 +366,7 @@ function templateVars(ctx) {
296
366
  session: ctx.session,
297
367
  level: ctx.level,
298
368
  links: ctx.links?.length ? ctx.links.join(' ') : undefined,
299
- message: composeBroadcastMessage(ctx),
369
+ message: composeBroadcastMessage(ctx, format),
300
370
  block: ctx.blockId,
301
371
  class: ctx.class,
302
372
  cost: ctx.cost,
@@ -330,9 +400,14 @@ export function renderSinkArgv(template, ctx) {
330
400
  }
331
401
  return argv.length > 0 ? argv : undefined;
332
402
  }
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);
403
+ /**
404
+ * Render one channel-message template with the same fail-closed placeholder
405
+ * contract as argv. `format` (Slack `mrkdwn` vs `plain`) flows into the shared
406
+ * `{message}` var so a Slack sink gets labeled links and an iMessage/owner sink
407
+ * gets the plain sentence.
408
+ */
409
+ export function renderSinkMessage(template, ctx, format = 'plain') {
410
+ const vars = templateVars(ctx, format);
336
411
  let missing = false;
337
412
  const rendered = template.replace(PLACEHOLDER, (whole, key) => {
338
413
  const value = vars[key];
@@ -367,8 +442,12 @@ export function renderSinkMessage(template, ctx) {
367
442
  * A `channel:` sink is gated by the same `minLevel` rule as a `command:` sink —
368
443
  * one level check for both shapes, so a dry-run plan is truthful regardless of
369
444
  * which shape an operator's sink uses.
445
+ *
446
+ * `meta` is used only to resolve a channel name to its real provider for the
447
+ * mrkdwn/plain format decision (`notify.transports`), the same map delivery uses;
448
+ * it is optional so a test can plan without a config snapshot (identity mapping).
370
449
  */
371
- export function planFeedBroadcast(config, ctx) {
450
+ export function planFeedBroadcast(config, ctx, meta) {
372
451
  if (!config)
373
452
  return [];
374
453
  const planned = [];
@@ -386,14 +465,28 @@ export function planFeedBroadcast(config, ctx) {
386
465
  // placeholder below).
387
466
  if (!isOwnerAlias(channel) && !sink.to?.trim())
388
467
  continue;
389
- const text = renderSinkMessage(sink.message ?? '{message}', ctx);
468
+ // Slack renders labeled links; every other channel (iMessage, telegram,
469
+ // discord, mailbox, desktop) stays plain (PHNX-3698). A DIRECT channel sink
470
+ // has one known provider, so its format is resolved here. The OWNER ALIAS
471
+ // fans out to every channel in owner.policy.normal — each with its OWN
472
+ // provider — so it can't pick one format now: it carries the ctx + template
473
+ // and re-renders per destination inside the owner fan-out (runChannelSink →
474
+ // sendToOwner), so the Slack destination turns blue while a sibling iMessage
475
+ // copy stays plain. The plain body computed here is the dry-run/fallback
476
+ // default. Keying on the resolved provider (not the raw name) matches what
477
+ // delivery does.
478
+ const owner = isOwnerAlias(channel);
479
+ const template = sink.message ?? '{message}';
480
+ const provider = owner ? channel : resolveSinkProvider(channel, meta);
481
+ const text = renderSinkMessage(template, ctx, sinkMessageFormat(provider));
390
482
  if (!text)
391
483
  continue;
392
484
  planned.push({
393
485
  name,
394
486
  channel,
395
- to: isOwnerAlias(channel) ? undefined : sink.to.trim(),
487
+ to: owner ? undefined : sink.to.trim(),
396
488
  text,
489
+ ...(owner ? { ctx, messageTemplate: template } : {}),
397
490
  });
398
491
  continue;
399
492
  }
@@ -502,7 +595,17 @@ async function runChannelSink(sink, meta) {
502
595
  registerBuiltinProviders();
503
596
  const owner = isOwnerAlias(sink.channel);
504
597
  if (owner) {
505
- const result = await sendToOwner(sink.text ?? '', { meta });
598
+ // Re-render the body PER owner destination so a Slack channel in the policy
599
+ // gets mrkdwn labeled links while iMessage stays plain (PHNX-3698). The
600
+ // fan-out (sendToOwner) resolves each destination's provider and asks this
601
+ // composer for the matching format. renderSinkMessage is fail-closed on a
602
+ // missing placeholder — the plan already dropped the sink if the template
603
+ // couldn't fill, so here it always resolves; `?? sink.text` is a belt-and-
604
+ // braces guard, never the normal path.
605
+ const composeForFormat = sink.ctx
606
+ ? (format) => renderSinkMessage(sink.messageTemplate ?? '{message}', sink.ctx, format) ?? sink.text ?? ''
607
+ : undefined;
608
+ const result = await sendToOwner(sink.text ?? '', { meta, composeForFormat });
506
609
  return { name, ok: result.ok, ...(result.error ? { error: result.error } : {}) };
507
610
  }
508
611
  const resolved = resolveSendEnvelope({
@@ -1845,9 +1845,6 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1845
1845
  if (agentId === 'copilot') {
1846
1846
  return registerHooksForCopilot(versionHome, manifest, resolveScript, managedPrefixes);
1847
1847
  }
1848
- if (agentId === 'kiro') {
1849
- return registerHooksForKiro(versionHome, manifest, resolveScript, managedPrefixes);
1850
- }
1851
1848
  if (agentId === 'goose') {
1852
1849
  return registerHooksForGoose(versionHome, manifest, resolveScript, managedPrefixes);
1853
1850
  }
@@ -2846,90 +2843,6 @@ function registerHooksForCopilot(versionHome, manifest, resolveScript, _managedP
2846
2843
  }
2847
2844
  return { registered, errors };
2848
2845
  }
2849
- /**
2850
- * Canonical hooks.yaml event names → Kiro v3 trigger names.
2851
- * Kiro v3 uses PascalCase triggers under `.kiro/hooks/*.json`
2852
- * (https://kiro.dev/docs/cli/v3/hooks/). Unmapped events are skipped.
2853
- */
2854
- const KIRO_EVENT_MAP = {
2855
- SessionStart: 'SessionStart',
2856
- Stop: 'Stop',
2857
- PreToolUse: 'PreToolUse',
2858
- PostToolUse: 'PostToolUse',
2859
- UserPromptSubmit: 'UserPromptSubmit',
2860
- };
2861
- /**
2862
- * Kiro triggers that evaluate the `matcher` regex (tool name, file path, or
2863
- * prompt text depending on the trigger). Lifecycle SessionStart/Stop always fire.
2864
- */
2865
- const KIRO_MATCHER_EVENTS = new Set([
2866
- 'PreToolUse',
2867
- 'PostToolUse',
2868
- 'UserPromptSubmit',
2869
- ]);
2870
- /** Managed filename under ~/.kiro/hooks/ — we own this file entirely. */
2871
- const KIRO_MANAGED_HOOKS_FILE = 'agents-cli-hooks.json';
2872
- /**
2873
- * Register hooks for Kiro CLI (v3 standalone hooks format).
2874
- *
2875
- * Each file under `~/.kiro/hooks/*.json` is:
2876
- * { "version": "v1", "hooks": [ { name, trigger, matcher?, action, timeout?, enabled? } ] }
2877
- *
2878
- * We rewrite a single managed file so GC is a rewrite and user-authored sibling
2879
- * JSON files are never touched. Embedded agent-config hooks (2.x) still work
2880
- * in Kiro but we only write the v3 path.
2881
- */
2882
- function registerHooksForKiro(versionHome, manifest, resolveScript, _managedPrefixes) {
2883
- const registered = [];
2884
- const errors = [];
2885
- const kiroHooksDir = path.join(versionHome, '.kiro', 'hooks');
2886
- fs.mkdirSync(kiroHooksDir, { recursive: true });
2887
- const hooks = [];
2888
- for (const [name, hookDef] of Object.entries(manifest)) {
2889
- if (!hookDef.events || hookDef.events.length === 0)
2890
- continue;
2891
- const commandPath = resolveHookCommand(name, hookDef, resolveScript);
2892
- if (!commandPath) {
2893
- errors.push(`${name}: script not found in user or system hooks dir`);
2894
- continue;
2895
- }
2896
- const timeout = hookDef.timeout ?? 60;
2897
- for (const event of hookDef.events) {
2898
- const trigger = KIRO_EVENT_MAP[event];
2899
- if (!trigger)
2900
- continue;
2901
- const entry = {
2902
- name,
2903
- trigger,
2904
- action: { type: 'command', command: commandPath },
2905
- timeout,
2906
- enabled: true,
2907
- };
2908
- if (KIRO_MATCHER_EVENTS.has(trigger) && hookDef.matcher) {
2909
- entry.matcher = hookDef.matcher;
2910
- }
2911
- // De-dupe on (name, trigger, matcher)
2912
- const existingIdx = hooks.findIndex((h) => h.name === entry.name &&
2913
- h.trigger === entry.trigger &&
2914
- (h.matcher ?? '') === (entry.matcher ?? ''));
2915
- if (existingIdx >= 0) {
2916
- hooks[existingIdx] = entry;
2917
- }
2918
- else {
2919
- hooks.push(entry);
2920
- }
2921
- registered.push(`${name} -> ${trigger}`);
2922
- }
2923
- }
2924
- const outPath = path.join(kiroHooksDir, KIRO_MANAGED_HOOKS_FILE);
2925
- try {
2926
- fs.writeFileSync(outPath, JSON.stringify({ version: 'v1', hooks }, null, 2) + '\n', 'utf-8');
2927
- }
2928
- catch (err) {
2929
- errors.push(`Failed to write ${KIRO_MANAGED_HOOKS_FILE}: ${err.message}`);
2930
- }
2931
- return { registered, errors };
2932
- }
2933
2846
  /**
2934
2847
  * Canonical hooks.yaml event names that goose supports (Open Plugins PascalCase).
2935
2848
  * Unmapped events are skipped. Goose ≥ 1.34.0.
@@ -177,7 +177,7 @@ const globalBinaryStrategy = {
177
177
  };
178
178
  /**
179
179
  * Harnesses installed by an official script that keeps a per-installation copy
180
- * or symlink farm (grok, cursor, antigravity, hermes, kiro, goose, …). The
180
+ * or symlink farm (grok, cursor, antigravity, hermes, goose, …). The
181
181
  * vendor artifact lands in a global location the installer owns, so the fetch
182
182
  * itself is not reversible; what IS per-installation — the version dir's binary
183
183
  * link farm — is staged and swapped so a failed re-import cannot strand the
@@ -108,13 +108,6 @@ export const MCP_TARGETS = {
108
108
  project: (cwd) => path.join(cwd, '.hermes', 'config.yaml'),
109
109
  format: 'yaml',
110
110
  },
111
- pi: {
112
- // omp reads user-scope MCP from ~/.omp/agent/.mcp.json and project MCP from
113
- // <root>/.mcp.json (both the Claude schema).
114
- home: (h) => path.join(h, '.omp', 'agent', '.mcp.json'),
115
- project: (cwd) => path.join(cwd, '.mcp.json'),
116
- format: 'claude-json',
117
- },
118
111
  muse: {
119
112
  home: (h) => path.join(h, '.config', 'muse', 'settings.json'),
120
113
  project: (cwd) => path.join(cwd, '.muse', 'settings.json'),
@@ -143,12 +136,6 @@ export const MCP_TARGETS = {
143
136
  format: null,
144
137
  unsupportedReason: 'Amp nests MCP under a settings key rather than a top-level map; schema not verified',
145
138
  },
146
- kiro: {
147
- home: (h) => path.join(h, '.kiro', 'settings', 'mcp.json'),
148
- project: (cwd) => path.join(cwd, '.kiro', 'settings', 'mcp.json'),
149
- format: null,
150
- unsupportedReason: 'mcp.json schema not verified against an installed Kiro',
151
- },
152
139
  goose: {
153
140
  home: (h) => path.join(h, '.config', 'goose', 'config.yaml'),
154
141
  project: (cwd) => path.join(cwd, '.goose', 'config.yaml'),
package/dist/lib/mcp.js CHANGED
@@ -529,8 +529,8 @@ export function writeMcpConfig(agentId, configPath, servers, mode = 'overwrite')
529
529
  }
530
530
  const format = target.format;
531
531
  switch (format) {
532
- // Claude's `{ "mcpServers": {...} }` schema, shared by cursor, kimi, droid,
533
- // omp (.mcp.json) and Oz (.warp/.mcp.json): stdio carries command/args/env,
532
+ // Claude's `{ "mcpServers": {...} }` schema, shared by cursor, kimi, droid
533
+ // and Oz (.warp/.mcp.json): stdio carries command/args/env,
534
534
  // remote carries url + optional headers.
535
535
  case 'claude-json': {
536
536
  const config = readExistingConfig(configPath, JSON.parse);
@@ -127,7 +127,7 @@ function rankCatalog(agent, models) {
127
127
  // Cursor and Pi (Oh My Pi) are cross-provider aggregators: their ids are
128
128
  // provider-qualified (`anthropic/…`, `openai/…`) and span vendors, so price of
129
129
  // the normalized base id is the only unifying rank signal.
130
- const aggregator = agent === 'cursor' || agent === 'pi';
130
+ const aggregator = agent === 'cursor';
131
131
  const scored = usable.map((m) => {
132
132
  const rawId = m.id;
133
133
  const baseId = aggregator ? normalizeAggregatorId(rawId) : rawId;
@@ -191,18 +191,6 @@ export function locateModelSource(agent, version) {
191
191
  return { path: pathBin, kind: 'cli' };
192
192
  return null;
193
193
  }
194
- if (agent === 'pi') {
195
- // omp (Oh My Pi) installs via `bun install -g`; a version-managed install
196
- // exposes it under node_modules/.bin/omp, otherwise it lives on PATH. We let
197
- // the CLI produce its own catalog via `omp models --json` (extractPiCatalog).
198
- const cli = path.join(versionDir, 'node_modules', '.bin', 'omp');
199
- if (fs.existsSync(cli))
200
- return { path: cli, kind: 'cli' };
201
- const pathBin = findOnPath('omp');
202
- if (pathBin)
203
- return { path: pathBin, kind: 'cli' };
204
- return null;
205
- }
206
194
  if (agent === 'muse') {
207
195
  // Muse Code is a self-updating native binary on PATH. It has no catalog
208
196
  // CLI; the published model IDs are fixed by Meta Model API docs. Point at
@@ -841,55 +829,6 @@ function extractKimiCatalog(binaryPath) {
841
829
  }
842
830
  return { models, aliases: {} };
843
831
  }
844
- /**
845
- * Extract Oh My Pi's catalog via `omp models --json`. omp is a cross-provider
846
- * aggregator: its catalog is the union of every provider it has a key for, so
847
- * ids are provider-qualified selectors (`anthropic/claude-opus-4-8`,
848
- * `openai/gpt-5.2`, `xai/grok-4`, `deepseek/deepseek-chat`, …) — exactly the
849
- * `provider/model` convention `ModelInfo.id` already uses. Output shape:
850
- * {"models":[{"provider":"anthropic","id":"claude-opus-4-8",
851
- * "selector":"anthropic/claude-opus-4-8","name":"Claude Opus 4.8",
852
- * "cost":{...}}, ...]}
853
- * The catalog is gated per-provider by key presence (no key -> that provider's
854
- * models are absent, and an empty env yields `{"models":[]}`). That is truthful:
855
- * the extractor surfaces exactly the providers the user has authenticated.
856
- * Pricing is attached uniformly by getModelCatalog via getModelPricing(id),
857
- * which strips the `provider/` prefix — so no per-catalog price shape here.
858
- */
859
- function extractPiCatalog(binaryPath) {
860
- let stdout;
861
- try {
862
- stdout = execFileSync(binaryPath, ['models', '--json'], {
863
- encoding: 'utf-8',
864
- stdio: ['ignore', 'pipe', 'ignore'],
865
- timeout: 15_000,
866
- maxBuffer: 64 * 1024 * 1024,
867
- });
868
- }
869
- catch {
870
- return { models: [], aliases: {} };
871
- }
872
- let parsed;
873
- try {
874
- parsed = JSON.parse(stdout.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''));
875
- }
876
- catch {
877
- return { models: [], aliases: {} };
878
- }
879
- if (!parsed || !Array.isArray(parsed.models))
880
- return { models: [], aliases: {} };
881
- const models = [];
882
- const seen = new Set();
883
- for (const m of parsed.models) {
884
- // Prefer the provider-qualified selector; fall back to provider/id.
885
- const id = m.selector || (m.provider && m.id ? `${m.provider}/${m.id}` : m.id);
886
- if (!id || seen.has(id))
887
- continue;
888
- seen.add(id);
889
- models.push({ id, displayName: typeof m.name === 'string' ? m.name : undefined });
890
- }
891
- return { models, aliases: {} };
892
- }
893
832
  /**
894
833
  * Build (or load from cache) the model catalog for a specific (agent, version).
895
834
  * Cache is keyed on source-file mtime (binary or js module), so re-extracts
@@ -967,8 +906,6 @@ export function getModelCatalog(agent, version) {
967
906
  ({ models, aliases } = extractKimiCatalog(src.path));
968
907
  else if (agent === 'grok')
969
908
  ({ models, aliases } = extractGrokCatalog(src.path));
970
- else if (agent === 'pi')
971
- ({ models, aliases } = extractPiCatalog(src.path));
972
909
  else if (agent === 'muse')
973
910
  ({ models, aliases } = extractMuseCatalog());
974
911
  }
@@ -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
+ }