@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.
- package/CHANGELOG.md +46 -0
- package/README.md +17 -14
- package/dist/bootstrap.js +3 -0
- package/dist/commands/accounts.js +23 -7
- package/dist/commands/exec.js +33 -23
- package/dist/commands/feed.js +20 -7
- package/dist/commands/import.js +2 -2
- package/dist/commands/models.js +2 -2
- package/dist/commands/monitors.js +3 -0
- package/dist/commands/permissions.js +2 -2
- package/dist/commands/projects.d.ts +26 -6
- package/dist/commands/projects.js +55 -22
- package/dist/commands/rules.js +1 -1
- package/dist/commands/send.js +35 -2
- package/dist/commands/sessions-inject.d.ts +58 -0
- package/dist/commands/sessions-inject.js +143 -7
- package/dist/commands/sessions-picker.js +1 -0
- package/dist/commands/share.js +43 -14
- package/dist/commands/ssh.js +205 -2
- package/dist/commands/traces.js +1 -1
- package/dist/lib/account-capabilities.js +0 -2
- package/dist/lib/account-registry.d.ts +3 -3
- package/dist/lib/account-registry.js +25 -7
- package/dist/lib/accounting/usage.d.ts +7 -2
- package/dist/lib/accounting/usage.js +142 -10
- package/dist/lib/acp/client.d.ts +1 -1
- package/dist/lib/acp/client.js +12 -1
- package/dist/lib/acp/harnesses.js +1 -1
- package/dist/lib/add-dir.js +0 -2
- package/dist/lib/agent-cli-commands.js +0 -2
- package/dist/lib/agent-spec/agents.d.ts +1 -1
- package/dist/lib/agent-spec/agents.js +2 -83
- package/dist/lib/boot-profile.d.ts +14 -0
- package/dist/lib/boot-profile.js +66 -0
- package/dist/lib/browser/ipc.js +9 -1
- package/dist/lib/browser/remote-control.d.ts +6 -3
- package/dist/lib/browser/remote-control.js +6 -3
- package/dist/lib/browser/service.d.ts +4 -1
- package/dist/lib/browser/service.js +7 -1
- package/dist/lib/channels/providers/desktop.d.ts +5 -4
- package/dist/lib/channels/providers/desktop.js +5 -4
- package/dist/lib/channels/registry.d.ts +6 -0
- package/dist/lib/channels/send.d.ts +11 -2
- package/dist/lib/channels/send.js +11 -2
- package/dist/lib/claude-account-token.js +108 -4
- package/dist/lib/cloud/rush.d.ts +10 -2
- package/dist/lib/cloud/rush.js +15 -9
- package/dist/lib/daemon/daemon.js +28 -8
- package/dist/lib/daemon/runner.js +6 -2
- package/dist/lib/devices/health.d.ts +38 -2
- package/dist/lib/devices/health.js +43 -5
- package/dist/lib/devices/worker-pick.d.ts +1 -1
- package/dist/lib/devices/worker-pick.js +4 -1
- package/dist/lib/exec.d.ts +2 -2
- package/dist/lib/exec.js +7 -34
- package/dist/lib/feed-broadcast.d.ts +47 -5
- package/dist/lib/feed-broadcast.js +127 -24
- package/dist/lib/hooks/install.js +0 -87
- package/dist/lib/installations/strategies.js +1 -1
- package/dist/lib/mcp-registry.js +0 -13
- package/dist/lib/mcp.js +2 -2
- package/dist/lib/model-tiers.js +1 -1
- package/dist/lib/models.js +0 -63
- package/dist/lib/monitors/engine.js +18 -0
- package/dist/lib/monitors/sources/command.js +13 -3
- package/dist/lib/monitors/sources/failure.d.ts +32 -0
- package/dist/lib/monitors/sources/failure.js +52 -0
- package/dist/lib/monitors/sources/types.d.ts +9 -0
- package/dist/lib/notify.d.ts +11 -0
- package/dist/lib/notify.js +17 -4
- package/dist/lib/owner-message.d.ts +55 -0
- package/dist/lib/owner-message.js +64 -0
- package/dist/lib/permissions-registry.d.ts +0 -2
- package/dist/lib/permissions-registry.js +3 -50
- package/dist/lib/permissions.d.ts +3 -17
- package/dist/lib/permissions.js +4 -73
- package/dist/lib/run-trace-sync.d.ts +15 -0
- package/dist/lib/run-trace-sync.js +43 -21
- package/dist/lib/rush-session.d.ts +19 -0
- package/dist/lib/rush-session.js +24 -0
- package/dist/lib/secrets/drivers/rush.js +2 -1
- package/dist/lib/secrets/filestore.d.ts +4 -0
- package/dist/lib/secrets/filestore.js +164 -3
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +3 -0
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +12 -1
- package/dist/lib/session/db.js +20 -1
- package/dist/lib/session/discover.js +81 -1
- package/dist/lib/session/linear.d.ts +13 -0
- package/dist/lib/session/linear.js +44 -0
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/parse.js +2 -3
- package/dist/lib/session/prompt.d.ts +7 -1
- package/dist/lib/session/prompt.js +12 -2
- package/dist/lib/session/recovery.d.ts +21 -12
- package/dist/lib/session/recovery.js +29 -11
- package/dist/lib/session/remote/watch.js +5 -2
- package/dist/lib/session/state.js +11 -13
- package/dist/lib/share/backend.d.ts +2 -2
- package/dist/lib/share/backend.js +20 -9
- package/dist/lib/share/delete.d.ts +5 -1
- package/dist/lib/share/delete.js +7 -2
- package/dist/lib/share/http-error.d.ts +52 -0
- package/dist/lib/share/http-error.js +65 -0
- package/dist/lib/share/publish.d.ts +13 -3
- package/dist/lib/share/publish.js +19 -15
- package/dist/lib/share/worker-template.js +5 -1
- package/dist/lib/sink-format.d.ts +34 -0
- package/dist/lib/sink-format.js +17 -0
- package/dist/lib/smart-launch.js +27 -4
- package/dist/lib/staleness/detectors/permissions.js +0 -20
- package/dist/lib/staleness/writers/commands.js +1 -1
- package/dist/lib/staleness/writers/hooks.js +2 -2
- package/dist/lib/storage/index.d.ts +14 -0
- package/dist/lib/storage/index.js +14 -0
- package/dist/lib/storage/selection.d.ts +48 -0
- package/dist/lib/storage/selection.js +39 -0
- package/dist/lib/storage/visibility.d.ts +82 -0
- package/dist/lib/storage/visibility.js +99 -0
- package/dist/lib/subagents-registry.js +2 -12
- package/dist/lib/subagents.d.ts +0 -10
- package/dist/lib/subagents.js +0 -37
- package/dist/lib/teams/agents.js +3 -1
- package/dist/lib/teams/placement-probe.js +1 -0
- package/dist/lib/teams/scheduler.d.ts +8 -1
- package/dist/lib/teams/scheduler.js +4 -1
- package/dist/lib/traces/backend.js +13 -2
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/worktree/held.d.ts +166 -0
- package/dist/lib/worktree/held.js +368 -0
- 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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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 (
|
|
279
|
-
// Blank line before the footer
|
|
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(
|
|
347
|
+
parts.push(footer);
|
|
283
348
|
}
|
|
284
349
|
return parts.join('\n').trim();
|
|
285
350
|
}
|
|
286
|
-
/**
|
|
287
|
-
|
|
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
|
-
/**
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
package/dist/lib/mcp-registry.js
CHANGED
|
@@ -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
|
-
//
|
|
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);
|
package/dist/lib/model-tiers.js
CHANGED
|
@@ -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'
|
|
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;
|
package/dist/lib/models.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|