@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
|
@@ -4,15 +4,18 @@ import type { SessionAgentId, SessionMeta } from './types.js';
|
|
|
4
4
|
/** One capability boundary for every surface that advertises faithful Resume. */
|
|
5
5
|
export declare function sessionAgentSupportsResume(agent: SessionAgentId): boolean;
|
|
6
6
|
/**
|
|
7
|
-
* The account a recovery should authenticate as. Present
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* The account a recovery should authenticate as. Present when resume rotates
|
|
8
|
+
* AWAY from the session's original login (an account limit) to a healthy
|
|
9
|
+
* sibling of the SAME harness. A `providerAccount` is a durable setup-token /
|
|
10
|
+
* API-key account (RUSH-3182) injected via the `--account` spawn path
|
|
11
|
+
* (`resolveSpawnAccount` → `accountEnv`); it is the only kind that can
|
|
12
12
|
* authenticate a NATIVE resume in the origin version home, because a native
|
|
13
|
-
* login lives in its own isolated home and cannot be forwarded.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* login lives in its own isolated home and cannot be forwarded. It is also
|
|
14
|
+
* required on `/continue` when the balanced pick is a provider: exec only
|
|
15
|
+
* injects from this field, so omitting it would launch the version home's
|
|
16
|
+
* native login — the exhausted origin in the PHNX-3674 fixture. Absent means
|
|
17
|
+
* "use the launched version home's own native login" (the healthy-origin happy
|
|
18
|
+
* path, or `/continue` on a healthy native sibling).
|
|
16
19
|
*/
|
|
17
20
|
export interface RecoveryAccount {
|
|
18
21
|
providerAccount: string;
|
|
@@ -30,6 +33,7 @@ export type SessionRecoveryTarget = {
|
|
|
30
33
|
mode: 'continue';
|
|
31
34
|
agent: AgentId;
|
|
32
35
|
version: string;
|
|
36
|
+
account?: RecoveryAccount;
|
|
33
37
|
reason: string;
|
|
34
38
|
};
|
|
35
39
|
export type NativeResumeInspection = {
|
|
@@ -59,10 +63,15 @@ export declare function inspectNativeResumeSession(session: SessionMeta, version
|
|
|
59
63
|
/**
|
|
60
64
|
* Decide how a durable session resumes on the device that owns it.
|
|
61
65
|
*
|
|
62
|
-
* Native resume is legal only in the exact origin version's isolated home
|
|
63
|
-
* only while that
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
+
* Native resume is legal only in the exact origin version's isolated home,
|
|
67
|
+
* and only while that home owns the indexed transcript AND some injectable
|
|
68
|
+
* credential for this harness is healthy: the origin login itself, or a
|
|
69
|
+
* provider account rotated in when the origin is usage-limited (PHNX-3626).
|
|
70
|
+
* Every other successful path stays on the same harness and uses `/continue`,
|
|
71
|
+
* whose indexed transcript reader can reach retained version trash. A
|
|
72
|
+
* `/continue` pick of a provider account carries RecoveryAccount so exec
|
|
73
|
+
* injects it instead of launching the version home's native login
|
|
74
|
+
* (PHNX-3674). No healthy same-harness account is a loud failure.
|
|
66
75
|
*/
|
|
67
76
|
export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean, nativeInspection?: NativeResumeInspection): SessionRecoveryTarget;
|
|
68
77
|
/**
|
|
@@ -50,6 +50,16 @@ function sourceReason(session, candidates) {
|
|
|
50
50
|
? `origin ${session.agent}@${session.version} has no native resume form`
|
|
51
51
|
: `origin ${session.agent}@${session.version} is ${readiness.reason}`;
|
|
52
52
|
}
|
|
53
|
+
function recoveryAccountFromCandidate(candidate) {
|
|
54
|
+
const providerAccount = candidate.providerAccount;
|
|
55
|
+
if (!providerAccount)
|
|
56
|
+
return undefined;
|
|
57
|
+
return {
|
|
58
|
+
providerAccount,
|
|
59
|
+
label: candidate.accountLabel || providerAccount,
|
|
60
|
+
email: candidate.email,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
53
63
|
function isPathInside(candidate, dir) {
|
|
54
64
|
const rel = path.relative(dir, candidate);
|
|
55
65
|
return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
@@ -146,10 +156,15 @@ export function inspectNativeResumeSession(session, versionHome) {
|
|
|
146
156
|
/**
|
|
147
157
|
* Decide how a durable session resumes on the device that owns it.
|
|
148
158
|
*
|
|
149
|
-
* Native resume is legal only in the exact origin version's isolated home
|
|
150
|
-
* only while that
|
|
151
|
-
*
|
|
152
|
-
*
|
|
159
|
+
* Native resume is legal only in the exact origin version's isolated home,
|
|
160
|
+
* and only while that home owns the indexed transcript AND some injectable
|
|
161
|
+
* credential for this harness is healthy: the origin login itself, or a
|
|
162
|
+
* provider account rotated in when the origin is usage-limited (PHNX-3626).
|
|
163
|
+
* Every other successful path stays on the same harness and uses `/continue`,
|
|
164
|
+
* whose indexed transcript reader can reach retained version trash. A
|
|
165
|
+
* `/continue` pick of a provider account carries RecoveryAccount so exec
|
|
166
|
+
* injects it instead of launching the version home's native login
|
|
167
|
+
* (PHNX-3674). No healthy same-harness account is a loud failure.
|
|
153
168
|
*/
|
|
154
169
|
export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume, nativeInspection) {
|
|
155
170
|
const agent = runnableSessionAgent(session);
|
|
@@ -175,18 +190,17 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
|
|
|
175
190
|
?? inspectNativeResumeSession(session, getVersionHomePath(agent, session.version));
|
|
176
191
|
if (inspection.available) {
|
|
177
192
|
const rotated = pickBalancedCandidate(candidates.filter((c) => c.providerAccount && c.accountKey !== source.accountKey));
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
193
|
+
const account = rotated ? recoveryAccountFromCandidate(rotated.picked) : undefined;
|
|
194
|
+
if (account) {
|
|
180
195
|
// `originLimited` guarantees the origin is unhealthy with a limit reason.
|
|
181
196
|
const why = originReadiness.ready ? 'limited' : originReadiness.reason;
|
|
182
|
-
const label = rotated.picked.accountLabel || providerAccount;
|
|
183
197
|
return {
|
|
184
198
|
mode: 'native',
|
|
185
199
|
agent,
|
|
186
200
|
version: session.version,
|
|
187
201
|
cwd: inspection.cwd,
|
|
188
|
-
account
|
|
189
|
-
reason: `origin ${agent}@${session.version} account is ${why}; rotated to healthy ${label} and resuming natively in the same home`,
|
|
202
|
+
account,
|
|
203
|
+
reason: `origin ${agent}@${session.version} account is ${why}; rotated to healthy ${account.label} and resuming natively in the same home`,
|
|
190
204
|
};
|
|
191
205
|
}
|
|
192
206
|
}
|
|
@@ -203,6 +217,8 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
|
|
|
203
217
|
throw new SessionRecoveryError(`Cannot recover session ${session.shortId} on ${device}; origin ${agent}@${session.version ?? 'unknown'}. ${detail}`);
|
|
204
218
|
}
|
|
205
219
|
const version = selection.picked.version;
|
|
220
|
+
const account = recoveryAccountFromCandidate(selection.picked);
|
|
221
|
+
const continueWith = account ? `healthy ${account.label}` : `healthy ${agent}@${version}`;
|
|
206
222
|
// Native resume without an injected RecoveryAccount is valid only for the
|
|
207
223
|
// exact healthy origin login. A balanced same-version provider selected for
|
|
208
224
|
// a signed-out/revoked origin must stay on /continue; otherwise we would open
|
|
@@ -223,14 +239,16 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
|
|
|
223
239
|
mode: 'continue',
|
|
224
240
|
agent,
|
|
225
241
|
version,
|
|
226
|
-
|
|
242
|
+
...(account ? { account } : {}),
|
|
243
|
+
reason: `${inspection.reason}; continuing with ${continueWith}`,
|
|
227
244
|
};
|
|
228
245
|
}
|
|
229
246
|
return {
|
|
230
247
|
mode: 'continue',
|
|
231
248
|
agent,
|
|
232
249
|
version,
|
|
233
|
-
|
|
250
|
+
...(account ? { account } : {}),
|
|
251
|
+
reason: `${sourceReason(session, candidates)}; continuing with ${continueWith}`,
|
|
234
252
|
};
|
|
235
253
|
}
|
|
236
254
|
/**
|
|
@@ -265,9 +265,12 @@ export function readPreviousSessionsForWatch(scope) {
|
|
|
265
265
|
try {
|
|
266
266
|
return querySessions({
|
|
267
267
|
machine: normalizeHost(scope),
|
|
268
|
-
|
|
268
|
+
agents: ['claude', 'codex', 'muse', 'opencode'],
|
|
269
|
+
sinceMs: Date.now() - 7 * 24 * 60 * 60 * 1000,
|
|
269
270
|
excludeTeamOrigin: true,
|
|
270
|
-
})
|
|
271
|
+
})
|
|
272
|
+
.filter((session) => !session.archived && Boolean(session.filePath))
|
|
273
|
+
.slice(0, SESSION_WATCH_PREVIOUS_LIMIT);
|
|
271
274
|
}
|
|
272
275
|
catch {
|
|
273
276
|
return [];
|
|
@@ -19,6 +19,7 @@ import { isCompletedTodoStatus, SNAPSHOT_TODO_TOOLS, summarizeToolUse } from './
|
|
|
19
19
|
import { isShellExecTool } from './shell-programs.js';
|
|
20
20
|
import { classifyFileChanges } from './digest.js';
|
|
21
21
|
import { extractArtifacts } from './highlights.js';
|
|
22
|
+
import { LINEAR_KEY_DENYLIST, linearIssueKeys } from './linear.js';
|
|
22
23
|
/**
|
|
23
24
|
* Detect per-session rate-limit / usage-limit signals in assistant or error
|
|
24
25
|
* text (RUSH-1523). Matches the same shapes the ext's prewarm detectBlockingPrompt
|
|
@@ -187,15 +188,12 @@ export function extractRecentDirectoriesTouched(events, cwd) {
|
|
|
187
188
|
const QUESTION_TRAILING = /\?["'”)\]]?\s*$/;
|
|
188
189
|
const QUESTION_PHRASE = /\b(shall i|should i|do you want|would you like|which (?:one|option|approach|of)|can you (?:confirm|clarify)|please (?:confirm|clarify|advise)|let me know|are you (?:ok|okay|sure)|proceed\?)\b/i;
|
|
189
190
|
/**
|
|
190
|
-
* Linear/Jira-style ref
|
|
191
|
-
*
|
|
192
|
-
*
|
|
191
|
+
* Linear/Jira-style ref detection reuses the canonical key matcher +
|
|
192
|
+
* {@link LINEAR_KEY_DENYLIST} from `./linear.js`, so the transcript detector and
|
|
193
|
+
* the owner-ping linkifier agree on what a real key is (no second copy to drift).
|
|
193
194
|
*/
|
|
194
|
-
const TICKET_RE = /\b([A-Z]{2,6}-\d{1,6})\b/;
|
|
195
195
|
/** Lowercase branch form (Linear branch names): muqsit/rush-1234-fix. */
|
|
196
196
|
const TICKET_BRANCH_RE = /(?:^|[/_-])([a-z]{2,6})-(\d{2,6})(?=[/_-]|$)/;
|
|
197
|
-
/** Keys that look like tickets but aren't — avoid false positives from branches. */
|
|
198
|
-
const TICKET_DENYLIST = new Set(['UTF', 'SHA', 'ISO', 'RFC', 'IPV', 'X86', 'ARM', 'MP', 'H']);
|
|
199
197
|
const PR_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/pull\/(\d+)/;
|
|
200
198
|
// Either separator: a Windows session cwd is `…\.agents\worktrees\<slug>`, and a
|
|
201
199
|
// forward-slash-only pattern silently derived no slug there (the RUSH-2358
|
|
@@ -283,15 +281,15 @@ export function detectWorktree(cwd, branch) {
|
|
|
283
281
|
/** Detect a tracker ticket from free text (prompt/topic) then a branch name. */
|
|
284
282
|
export function detectTicket(text, branch) {
|
|
285
283
|
if (text) {
|
|
286
|
-
const
|
|
287
|
-
if (
|
|
288
|
-
return { id:
|
|
284
|
+
const key = linearIssueKeys(text)[0];
|
|
285
|
+
if (key)
|
|
286
|
+
return { id: key };
|
|
289
287
|
}
|
|
290
288
|
if (branch) {
|
|
291
289
|
const m = branch.match(TICKET_BRANCH_RE);
|
|
292
290
|
if (m) {
|
|
293
291
|
const key = m[1].toUpperCase();
|
|
294
|
-
if (!
|
|
292
|
+
if (!LINEAR_KEY_DENYLIST.has(key))
|
|
295
293
|
return { id: `${key}-${m[2]}` };
|
|
296
294
|
}
|
|
297
295
|
}
|
|
@@ -355,9 +353,9 @@ export function isTicketCreateTool(name, command) {
|
|
|
355
353
|
export function extractCreatedTicket(text) {
|
|
356
354
|
if (!text)
|
|
357
355
|
return undefined;
|
|
358
|
-
const lin = text
|
|
359
|
-
if (lin
|
|
360
|
-
return lin
|
|
356
|
+
const lin = linearIssueKeys(text)[0];
|
|
357
|
+
if (lin)
|
|
358
|
+
return lin;
|
|
361
359
|
const gh = text.match(GH_ISSUE_URL_RE);
|
|
362
360
|
if (gh)
|
|
363
361
|
return `#${gh[1]}`;
|
|
@@ -88,8 +88,8 @@ export declare function phoenixIdBaseForDeploy(opts?: {
|
|
|
88
88
|
domain?: string;
|
|
89
89
|
}): string | undefined;
|
|
90
90
|
/**
|
|
91
|
-
* Pick the principal. Signed-in
|
|
92
|
-
* override → managed; otherwise BYO.
|
|
91
|
+
* Pick the principal via the shared selection policy. Signed-in AND no explicit
|
|
92
|
+
* BYO override → managed; otherwise BYO.
|
|
93
93
|
*/
|
|
94
94
|
export declare function shouldUseManaged(opts?: ResolveShareBackendOpts): boolean;
|
|
95
95
|
export declare function resolveShareBackend(opts?: ResolveShareBackendOpts): ShareBackend;
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* - `AGENTS_SHARE_BACKEND=byo`
|
|
19
19
|
*/
|
|
20
20
|
import { PHOENIX_ID_BASE, readSession } from '../identity/client.js';
|
|
21
|
+
import { selectStorageBackendKind } from '../storage/selection.js';
|
|
21
22
|
import { DEFAULT_SHARE_DOMAIN, readShareConfig, readWriteToken, readWriteTokenEnv, } from './config.js';
|
|
22
23
|
/** Env var that forces the BYO path. Value must be exactly `byo`. */
|
|
23
24
|
export const SHARE_BACKEND_ENV = 'AGENTS_SHARE_BACKEND';
|
|
@@ -82,18 +83,28 @@ export function phoenixIdBaseForDeploy(opts = {}, cfg) {
|
|
|
82
83
|
return base;
|
|
83
84
|
}
|
|
84
85
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
86
|
+
* The share surface's BYO-override signals: an explicit `--byo`, a
|
|
87
|
+
* caller-supplied static write token, or `AGENTS_SHARE_BACKEND=byo`. Detecting
|
|
88
|
+
* WHICH signals count is surface-specific; the managed-vs-BYO decision itself is
|
|
89
|
+
* the shared policy (`selectStorageBackendKind`). A persisted BYO endpoint config
|
|
90
|
+
* is deliberately NOT an override here — a signed-in user with a stale BYO config
|
|
91
|
+
* still publishes to managed unless they opt out explicitly (the product's
|
|
92
|
+
* managed-first contract).
|
|
87
93
|
*/
|
|
88
|
-
|
|
94
|
+
function shareByoOverride(opts) {
|
|
89
95
|
if (opts.byo === true)
|
|
90
|
-
return
|
|
96
|
+
return true;
|
|
91
97
|
if (opts.writeToken)
|
|
92
|
-
return
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
return true;
|
|
99
|
+
return (process.env[SHARE_BACKEND_ENV] ?? '').trim().toLowerCase() === 'byo';
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Pick the principal via the shared selection policy. Signed-in AND no explicit
|
|
103
|
+
* BYO override → managed; otherwise BYO.
|
|
104
|
+
*/
|
|
105
|
+
export function shouldUseManaged(opts = {}) {
|
|
106
|
+
return (selectStorageBackendKind({ byoOverride: shareByoOverride(opts), session: opts.session }) ===
|
|
107
|
+
'managed');
|
|
97
108
|
}
|
|
98
109
|
export function resolveShareBackend(opts = {}) {
|
|
99
110
|
if (shouldUseManaged(opts)) {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { type ShareConfig } from './config.js';
|
|
2
|
-
/** DI seam for tests — override the real HTTP DELETE.
|
|
2
|
+
/** DI seam for tests — override the real HTTP DELETE. `body`/`retryAfter` carry
|
|
3
|
+
* the Worker's error response (read only on `!ok`) so a failed delete surfaces
|
|
4
|
+
* WHY, via the bounded {@link extractShareHttpError}. */
|
|
3
5
|
export type DeleteFn = (url: string, headers: Record<string, string>) => Promise<{
|
|
4
6
|
ok: boolean;
|
|
5
7
|
status: number;
|
|
8
|
+
body?: string;
|
|
9
|
+
retryAfter?: string;
|
|
6
10
|
}>;
|
|
7
11
|
/** DI seam for tests — override the real HTTP existence check (HEAD). */
|
|
8
12
|
export type CheckFn = (url: string) => Promise<{
|
package/dist/lib/share/delete.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// the operation is reported as successful.
|
|
10
10
|
import { resolveShareBackend } from './backend.js';
|
|
11
11
|
import { buildShareKey, resolveShareUsername } from './publish.js';
|
|
12
|
+
import { extractShareHttpError, formatShareHttpErrorDetail } from './http-error.js';
|
|
12
13
|
/**
|
|
13
14
|
* Normalize any of the three accepted target forms to the R2 key that publish
|
|
14
15
|
* would have written:
|
|
@@ -59,7 +60,10 @@ async function defaultCheck(url) {
|
|
|
59
60
|
}
|
|
60
61
|
async function defaultDelete(url, headers) {
|
|
61
62
|
const res = await fetch(url, { method: 'DELETE', headers });
|
|
62
|
-
|
|
63
|
+
if (res.ok)
|
|
64
|
+
return { ok: true, status: res.status };
|
|
65
|
+
const body = await res.text().catch(() => undefined);
|
|
66
|
+
return { ok: false, status: res.status, body, retryAfter: res.headers.get('retry-after') ?? undefined };
|
|
63
67
|
}
|
|
64
68
|
async function defaultRevisionsFetch(url) {
|
|
65
69
|
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
|
@@ -103,7 +107,8 @@ export async function deleteObject(endpoint, key, opts = {}) {
|
|
|
103
107
|
const existedBefore = before.status !== 404;
|
|
104
108
|
const r = await del(url, { authorization: `Bearer ${endpoint.token}` });
|
|
105
109
|
if (!r.ok) {
|
|
106
|
-
|
|
110
|
+
const detail = formatShareHttpErrorDetail(extractShareHttpError({ status: r.status, body: r.body, retryAfter: r.retryAfter }));
|
|
111
|
+
throw new Error(`Delete failed (${r.status}) for ${url}${detail}. Check the bearer (Phoenix session or WRITE_TOKEN), or that 'agents artifacts setup' completed.`);
|
|
107
112
|
}
|
|
108
113
|
const after = await check(url);
|
|
109
114
|
const verified404 = after.status === 404;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE bounded extractor for the share Worker's error responses.
|
|
3
|
+
*
|
|
4
|
+
* Every share HTTP call (publish PUT, list/revisions GET, delete DELETE)
|
|
5
|
+
* previously threw on `!ok` with only the status code, discarding the JSON error
|
|
6
|
+
* body the Worker actually returns (`{"error":"…"}` for a 400/413/429, plus a
|
|
7
|
+
* `Retry-After` on a 429). The result: a rate-limited or quota-exceeded publish
|
|
8
|
+
* surfaced as a bare "Publish failed (429)" with no reason. This pulls the
|
|
9
|
+
* server's own `error` string and preserves the status + `Retry-After`, so the
|
|
10
|
+
* user sees why.
|
|
11
|
+
*
|
|
12
|
+
* Bounded and safe by construction: it parses at most {@link MAX_ERROR_BODY_BYTES}
|
|
13
|
+
* of the body, only lifts a string `error` field, caps its length, and NEVER
|
|
14
|
+
* surfaces an arbitrary body — a non-JSON or oversized body yields no message,
|
|
15
|
+
* so an HTML error page or a huge payload can't leak into a CLI error string.
|
|
16
|
+
*/
|
|
17
|
+
/** The raw pieces a caller's fetch wrapper hands in — status plus the (optional)
|
|
18
|
+
* response body text and `Retry-After` header value. */
|
|
19
|
+
export interface ShareHttpResponse {
|
|
20
|
+
status: number;
|
|
21
|
+
/** The response body text, when the caller read it (only on `!ok` paths). */
|
|
22
|
+
body?: string;
|
|
23
|
+
/** The `Retry-After` header value, when present (a 429 carries it). */
|
|
24
|
+
retryAfter?: string;
|
|
25
|
+
}
|
|
26
|
+
/** The extracted, bounded error facts. `serverMessage` is present only when the
|
|
27
|
+
* body parsed as JSON carrying a non-empty string `error`. */
|
|
28
|
+
export interface ShareHttpError {
|
|
29
|
+
status: number;
|
|
30
|
+
serverMessage?: string;
|
|
31
|
+
retryAfter?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Never parse more than this many chars of a body — a legitimate `{"error":"…"}`
|
|
34
|
+
* is tiny; a larger body is an HTML page or noise we deliberately don't surface. */
|
|
35
|
+
export declare const MAX_ERROR_BODY_BYTES = 4096;
|
|
36
|
+
/** Cap the surfaced message so even a pathological (but valid-JSON) `error`
|
|
37
|
+
* string can't blow up the CLI output. */
|
|
38
|
+
export declare const MAX_ERROR_MESSAGE_CHARS = 300;
|
|
39
|
+
/**
|
|
40
|
+
* Extract the bounded error facts from a share Worker response. Pulls a string
|
|
41
|
+
* `error` field from a small JSON body, preserves status + `Retry-After`, and
|
|
42
|
+
* never surfaces an arbitrary body.
|
|
43
|
+
*/
|
|
44
|
+
export declare function extractShareHttpError(res: ShareHttpResponse): ShareHttpError;
|
|
45
|
+
/**
|
|
46
|
+
* A one-line suffix summarizing the server's structured error, or `''` when the
|
|
47
|
+
* response carried nothing extractable. Appended after the status-bearing prefix
|
|
48
|
+
* a caller already builds, e.g.:
|
|
49
|
+
* `Publish failed (429) for <url>` + `formatShareHttpErrorDetail(err)`
|
|
50
|
+
* → `Publish failed (429) for <url> — rate limit exceeded; retry after 37s`
|
|
51
|
+
*/
|
|
52
|
+
export declare function formatShareHttpErrorDetail(err: ShareHttpError): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE bounded extractor for the share Worker's error responses.
|
|
3
|
+
*
|
|
4
|
+
* Every share HTTP call (publish PUT, list/revisions GET, delete DELETE)
|
|
5
|
+
* previously threw on `!ok` with only the status code, discarding the JSON error
|
|
6
|
+
* body the Worker actually returns (`{"error":"…"}` for a 400/413/429, plus a
|
|
7
|
+
* `Retry-After` on a 429). The result: a rate-limited or quota-exceeded publish
|
|
8
|
+
* surfaced as a bare "Publish failed (429)" with no reason. This pulls the
|
|
9
|
+
* server's own `error` string and preserves the status + `Retry-After`, so the
|
|
10
|
+
* user sees why.
|
|
11
|
+
*
|
|
12
|
+
* Bounded and safe by construction: it parses at most {@link MAX_ERROR_BODY_BYTES}
|
|
13
|
+
* of the body, only lifts a string `error` field, caps its length, and NEVER
|
|
14
|
+
* surfaces an arbitrary body — a non-JSON or oversized body yields no message,
|
|
15
|
+
* so an HTML error page or a huge payload can't leak into a CLI error string.
|
|
16
|
+
*/
|
|
17
|
+
/** Never parse more than this many chars of a body — a legitimate `{"error":"…"}`
|
|
18
|
+
* is tiny; a larger body is an HTML page or noise we deliberately don't surface. */
|
|
19
|
+
export const MAX_ERROR_BODY_BYTES = 4096;
|
|
20
|
+
/** Cap the surfaced message so even a pathological (but valid-JSON) `error`
|
|
21
|
+
* string can't blow up the CLI output. */
|
|
22
|
+
export const MAX_ERROR_MESSAGE_CHARS = 300;
|
|
23
|
+
/**
|
|
24
|
+
* Extract the bounded error facts from a share Worker response. Pulls a string
|
|
25
|
+
* `error` field from a small JSON body, preserves status + `Retry-After`, and
|
|
26
|
+
* never surfaces an arbitrary body.
|
|
27
|
+
*/
|
|
28
|
+
export function extractShareHttpError(res) {
|
|
29
|
+
const out = { status: res.status };
|
|
30
|
+
const retryAfter = res.retryAfter?.trim();
|
|
31
|
+
if (retryAfter)
|
|
32
|
+
out.retryAfter = retryAfter;
|
|
33
|
+
const raw = res.body;
|
|
34
|
+
if (raw && raw.length <= MAX_ERROR_BODY_BYTES) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
38
|
+
const err = parsed.error;
|
|
39
|
+
if (typeof err === 'string' && err.trim()) {
|
|
40
|
+
out.serverMessage = err.trim().slice(0, MAX_ERROR_MESSAGE_CHARS);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Not JSON (an HTML error page, a proxy body, truncated bytes) — deliberately
|
|
46
|
+
// surface nothing rather than dump an arbitrary body into the CLI error.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A one-line suffix summarizing the server's structured error, or `''` when the
|
|
53
|
+
* response carried nothing extractable. Appended after the status-bearing prefix
|
|
54
|
+
* a caller already builds, e.g.:
|
|
55
|
+
* `Publish failed (429) for <url>` + `formatShareHttpErrorDetail(err)`
|
|
56
|
+
* → `Publish failed (429) for <url> — rate limit exceeded; retry after 37s`
|
|
57
|
+
*/
|
|
58
|
+
export function formatShareHttpErrorDetail(err) {
|
|
59
|
+
const parts = [];
|
|
60
|
+
if (err.serverMessage)
|
|
61
|
+
parts.push(err.serverMessage);
|
|
62
|
+
if (err.retryAfter)
|
|
63
|
+
parts.push(`retry after ${err.retryAfter}s`);
|
|
64
|
+
return parts.length ? ` — ${parts.join('; ')}` : '';
|
|
65
|
+
}
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { type ShareConfig } from './config.js';
|
|
2
2
|
import { type ShareBackendKind } from './backend.js';
|
|
3
|
-
|
|
3
|
+
import { type ShareVisibility } from '../storage/visibility.js';
|
|
4
|
+
/** The share upload result. `body`/`retryAfter` carry the server's error
|
|
5
|
+
* response so a failed publish can surface WHY (the Worker returns
|
|
6
|
+
* `{"error":"…"}` + a `Retry-After` on a 429); they are read only on `!ok`
|
|
7
|
+
* paths, and only ever fed through the bounded {@link extractShareHttpError}. */
|
|
8
|
+
export type PutResult = {
|
|
4
9
|
ok: boolean;
|
|
5
10
|
status: number;
|
|
6
11
|
url?: string;
|
|
7
|
-
|
|
12
|
+
/** Response body text, present on the `!ok` path so the error can be extracted. */
|
|
13
|
+
body?: string;
|
|
14
|
+
/** `Retry-After` header value, present on a 429. */
|
|
15
|
+
retryAfter?: string;
|
|
16
|
+
};
|
|
17
|
+
export type PutFn = (url: string, body: Buffer, headers: Record<string, string>) => Promise<PutResult>;
|
|
8
18
|
export interface PublishEndpoint {
|
|
9
19
|
baseUrl: string;
|
|
10
20
|
token: string;
|
|
@@ -102,7 +112,7 @@ export interface PublishOptions {
|
|
|
102
112
|
/** DI seam for tests — override provenance auto-capture (agent/session/host/repo/date). */
|
|
103
113
|
provenance?: ShareProvenance;
|
|
104
114
|
}
|
|
105
|
-
export type ShareVisibility
|
|
115
|
+
export { type ShareVisibility } from '../storage/visibility.js';
|
|
106
116
|
/** The visibility levels a publish `--visibility` may select — the Worker's own
|
|
107
117
|
* set. `private` (token-gated, PHNX-3654) is settable only at publish time,
|
|
108
118
|
* since it mints a viewer token the metadata-edit route can't carry, so it is
|
|
@@ -16,6 +16,8 @@ import { resolveGitHubUsername } from '../git.js';
|
|
|
16
16
|
import { resolveShareBackend, sanitizeShareNamespace } from './backend.js';
|
|
17
17
|
import { captureCover, OG_WIDTH, OG_HEIGHT, OG_SCALE } from './capture.js';
|
|
18
18
|
import { deriveMeta, injectOgMeta } from './og.js';
|
|
19
|
+
import { extractShareHttpError, formatShareHttpErrorDetail } from './http-error.js';
|
|
20
|
+
import { PUBLISH_VISIBILITY_LEVELS as STORAGE_PUBLISH_VISIBILITY_LEVELS, EDITABLE_VISIBILITY_LEVELS, resolveVisibility, } from '../storage/visibility.js';
|
|
19
21
|
import { injectAnalyticsBeacon } from './analytics.js';
|
|
20
22
|
import { prepareShareHtml } from './html.js';
|
|
21
23
|
/** The visibility levels a publish `--visibility` may select — the Worker's own
|
|
@@ -23,12 +25,12 @@ import { prepareShareHtml } from './html.js';
|
|
|
23
25
|
* since it mints a viewer token the metadata-edit route can't carry, so it is
|
|
24
26
|
* NOT in {@link SHARE_VISIBILITY_LEVELS} (the in-place `share visibility <level>`
|
|
25
27
|
* set). */
|
|
26
|
-
export const PUBLISH_VISIBILITY_LEVELS =
|
|
28
|
+
export const PUBLISH_VISIBILITY_LEVELS = STORAGE_PUBLISH_VISIBILITY_LEVELS;
|
|
27
29
|
/** The visibility levels an ALREADY-published share may be re-scoped to in place
|
|
28
30
|
* — the set `share visibility <target> <level>` and the inline owner control
|
|
29
31
|
* accept. Excludes `private`: re-scoping to token-gated needs a fresh viewer
|
|
30
32
|
* token, which only the publish path mints. */
|
|
31
|
-
export const SHARE_VISIBILITY_LEVELS =
|
|
33
|
+
export const SHARE_VISIBILITY_LEVELS = EDITABLE_VISIBILITY_LEVELS;
|
|
32
34
|
/**
|
|
33
35
|
* `--protected` / `{ protected: true }` map to `private` (token-gated — it wins
|
|
34
36
|
* over `--unlisted` when both are set, being the stronger control); `--unlisted`
|
|
@@ -36,17 +38,13 @@ export const SHARE_VISIBILITY_LEVELS = ['public', 'unlisted', 'me', 'org'];
|
|
|
36
38
|
* otherwise `visibility` (default public).
|
|
37
39
|
*/
|
|
38
40
|
export function resolveShareVisibility(opts = {}) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
opts.visibility === 'org') {
|
|
47
|
-
return opts.visibility;
|
|
48
|
-
}
|
|
49
|
-
return 'public';
|
|
41
|
+
// Delegates to the shared visibility resolver. The library fallback stays
|
|
42
|
+
// `public` so a lib caller that hasn't opted into the managed `me` default
|
|
43
|
+
// (e.g. `sessions share --public`, which expresses "public" as the absence of
|
|
44
|
+
// --unlisted) is never silently flipped. The PRODUCT default (`me` on managed)
|
|
45
|
+
// is applied by the `agents artifacts share` command surface, which knows the
|
|
46
|
+
// resolved backend — see `commands/share.ts`.
|
|
47
|
+
return resolveVisibility(opts);
|
|
50
48
|
}
|
|
51
49
|
/** The bytes of viewer-token entropy the `private` mode mints (128-bit; the
|
|
52
50
|
* base64url form is ~22 URL-safe chars). Well past the 64-bit floor a guessable
|
|
@@ -596,7 +594,12 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
|
596
594
|
const put = opts.uploader ??
|
|
597
595
|
(async (u, b, h) => {
|
|
598
596
|
const res = await fetch(u, { method: 'PUT', headers: h, body: new Uint8Array(b) });
|
|
599
|
-
|
|
597
|
+
// Read the error body only on failure, so the caller can surface the
|
|
598
|
+
// Worker's own `{"error":"…"}` + Retry-After (bounded by extractShareHttpError).
|
|
599
|
+
if (res.ok)
|
|
600
|
+
return { ok: true, status: res.status, url: u };
|
|
601
|
+
const body = await res.text().catch(() => undefined);
|
|
602
|
+
return { ok: false, status: res.status, url: u, body, retryAfter: res.headers.get('retry-after') ?? undefined };
|
|
600
603
|
});
|
|
601
604
|
let coverUrl;
|
|
602
605
|
const isHtml = /\.html?$/i.test(filePath);
|
|
@@ -748,7 +751,8 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
|
|
|
748
751
|
if (r.status === 409) {
|
|
749
752
|
throw new Error(`Handle '${username}' is already claimed by another account. The public URL namespace is the email local-part; two Phoenix users cannot share it.`);
|
|
750
753
|
}
|
|
751
|
-
|
|
754
|
+
const detail = formatShareHttpErrorDetail(extractShareHttpError({ status: r.status, body: r.body, retryAfter: r.retryAfter }));
|
|
755
|
+
throw new Error(`Publish failed (${r.status}) for ${pageUrl}${detail}. Check the write token, or that 'agents artifacts setup' completed.`);
|
|
752
756
|
}
|
|
753
757
|
// A token-gated page is only reachable WITH its key, so the URL we hand back
|
|
754
758
|
// (and store nowhere) carries it — https://<host>/<user>/<slug>?k=<token>.
|
|
@@ -1406,7 +1406,11 @@ function isIdentityGated(visibility) {
|
|
|
1406
1406
|
|
|
1407
1407
|
function managedCoverHeaders(visibility) {
|
|
1408
1408
|
const headers = new Headers({ 'content-type': 'image/png' });
|
|
1409
|
-
|
|
1409
|
+
// Token-gated (private) + identity-gated (me/org) covers must never be
|
|
1410
|
+
// cached by a shared proxy, and never indexed. RFC 9111: public on an
|
|
1411
|
+
// Authorization response authorizes reuse for later unauthenticated
|
|
1412
|
+
// requests keyed on /user/slug.png (PHNX-3676).
|
|
1413
|
+
if (isIdentityGated(visibility) || visibility === 'private') {
|
|
1410
1414
|
headers.set('cache-control', 'private, no-store');
|
|
1411
1415
|
headers.set('X-Robots-Tag', 'noindex');
|
|
1412
1416
|
} else {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How loudly a destination can render a link — the one decision that shapes the
|
|
3
|
+
* shared owner/broadcast `{message}` per channel (PHNX-3698).
|
|
4
|
+
*
|
|
5
|
+
* - `mrkdwn` — Slack, which renders `<url|label>` as blue tappable text. The
|
|
6
|
+
* session crumb and every ticket key the prose NAMES become inline labeled
|
|
7
|
+
* links, so nothing rides a trailing naked-URL line.
|
|
8
|
+
* - `plain` — iMessage, the owner-scoped rush message, a spawned `command:`
|
|
9
|
+
* sink, desktop banners: none can render a labeled link and a dumped naked
|
|
10
|
+
* URL reads as noise, so the message stays the human sentence with no URLs.
|
|
11
|
+
*
|
|
12
|
+
* The default is `plain`; only Slack opts into `mrkdwn`.
|
|
13
|
+
*
|
|
14
|
+
* This lives in its own tiny module because both the feed broadcast fan-out
|
|
15
|
+
* (`feed-broadcast.ts`) and the owner delivery fan-out (`notify.ts`) key on it,
|
|
16
|
+
* and those two modules already import each other — a shared symbol here keeps
|
|
17
|
+
* the format decision a single source of truth without a circular import.
|
|
18
|
+
*/
|
|
19
|
+
export type SinkMessageFormat = 'plain' | 'mrkdwn';
|
|
20
|
+
/**
|
|
21
|
+
* Only Slack renders `<url|label>`, so it is the one format that gets labeled
|
|
22
|
+
* links. iMessage / owner-scoped rush / command / desktop sinks stay `plain`
|
|
23
|
+
* (they can't turn `claude/6fc1db18` blue, and dumping the raw URL is worse than
|
|
24
|
+
* leaving the crumb unlinked — PHNX-3698).
|
|
25
|
+
*
|
|
26
|
+
* The argument is the **resolved provider name**, not a sink's declared channel:
|
|
27
|
+
* an operator can point an arbitrary channel name at the Slack provider through
|
|
28
|
+
* `notify.transports` (e.g. `eng-alerts -> slack`), and delivery keys off that
|
|
29
|
+
* resolved provider (`lookupTransport`), so the format decision must too —
|
|
30
|
+
* otherwise an aliased Slack sink would compose plain while delivering to Slack,
|
|
31
|
+
* or a name remapped AWAY from Slack would emit `<url|label>` markup a non-Slack
|
|
32
|
+
* transport shows literally.
|
|
33
|
+
*/
|
|
34
|
+
export declare function sinkMessageFormat(provider: string | undefined): SinkMessageFormat;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Only Slack renders `<url|label>`, so it is the one format that gets labeled
|
|
3
|
+
* links. iMessage / owner-scoped rush / command / desktop sinks stay `plain`
|
|
4
|
+
* (they can't turn `claude/6fc1db18` blue, and dumping the raw URL is worse than
|
|
5
|
+
* leaving the crumb unlinked — PHNX-3698).
|
|
6
|
+
*
|
|
7
|
+
* The argument is the **resolved provider name**, not a sink's declared channel:
|
|
8
|
+
* an operator can point an arbitrary channel name at the Slack provider through
|
|
9
|
+
* `notify.transports` (e.g. `eng-alerts -> slack`), and delivery keys off that
|
|
10
|
+
* resolved provider (`lookupTransport`), so the format decision must too —
|
|
11
|
+
* otherwise an aliased Slack sink would compose plain while delivering to Slack,
|
|
12
|
+
* or a name remapped AWAY from Slack would emit `<url|label>` markup a non-Slack
|
|
13
|
+
* transport shows literally.
|
|
14
|
+
*/
|
|
15
|
+
export function sinkMessageFormat(provider) {
|
|
16
|
+
return provider?.trim().toLowerCase() === 'slack' ? 'mrkdwn' : 'plain';
|
|
17
|
+
}
|