@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.
- package/CHANGELOG.md +30 -0
- package/README.md +14 -6
- package/dist/bootstrap.js +3 -0
- package/dist/commands/exec.js +31 -21
- package/dist/commands/feed.js +20 -7
- package/dist/commands/monitors.js +3 -0
- package/dist/commands/projects.d.ts +26 -6
- package/dist/commands/projects.js +55 -22
- package/dist/commands/send.js +29 -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/lib/accounting/usage.d.ts +7 -2
- package/dist/lib/accounting/usage.js +142 -10
- package/dist/lib/boot-profile.d.ts +14 -0
- package/dist/lib/boot-profile.js +66 -0
- package/dist/lib/channels/providers/desktop.d.ts +5 -4
- package/dist/lib/channels/providers/desktop.js +5 -4
- package/dist/lib/claude-account-token.js +108 -4
- 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.js +4 -0
- package/dist/lib/feed-broadcast.d.ts +64 -5
- package/dist/lib/feed-broadcast.js +124 -22
- 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/owner-message.d.ts +12 -0
- package/dist/lib/owner-message.js +44 -0
- package/dist/lib/run-trace-sync.d.ts +15 -0
- package/dist/lib/run-trace-sync.js +43 -21
- 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/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/smart-launch.js +27 -4
- 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/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/worktree/held.d.ts +166 -0
- package/dist/lib/worktree/held.js +368 -0
- 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
|
-
/**
|
|
163
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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 (
|
|
279
|
-
// Blank line before the footer
|
|
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(
|
|
363
|
+
parts.push(footer);
|
|
283
364
|
}
|
|
284
365
|
return parts.join('\n').trim();
|
|
285
366
|
}
|
|
286
|
-
/**
|
|
287
|
-
|
|
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
|
-
/**
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|
|
@@ -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;
|