@oxygen-agent/cli 1.1011.288 → 1.1011.326
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/README.md +1 -1
- package/dist/connectors-commands.d.ts +11 -0
- package/dist/connectors-commands.js +63 -0
- package/dist/index.js +14 -3
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +39 -6
- package/node_modules/@oxygen/shared/dist/contracts/connectors.d.ts +23 -0
- package/node_modules/@oxygen/shared/dist/contracts/connectors.js +34 -0
- package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +0 -6
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +8 -1
- package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/feature-gates.js +6 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +57 -8
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +74 -13
- package/node_modules/@oxygen/shared/dist/mcp-event-contract.d.ts +597 -0
- package/node_modules/@oxygen/shared/dist/mcp-event-contract.js +713 -0
- package/node_modules/@oxygen/shared/dist/mcp-events-enabled.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/mcp-events-enabled.js +14 -0
- package/node_modules/@oxygen/shared/dist/select-options.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/select-options.js +6 -1
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { type ContractFlagOverlay } from "./contract-flags.js";
|
|
3
|
+
type JsonOptions = {
|
|
4
|
+
json?: boolean;
|
|
5
|
+
};
|
|
6
|
+
type Handle = (command: string, options: JsonOptions, action: () => Promise<unknown>) => Promise<void>;
|
|
7
|
+
export declare const CONNECTORS_FLAG_OVERLAY: ContractFlagOverlay;
|
|
8
|
+
/** The PATCH body for the flags given; one of the two workspace flags is required. */
|
|
9
|
+
export declare function buildConnectorUpdateBody(options: Record<string, unknown>): Record<string, unknown>;
|
|
10
|
+
export declare function registerConnectorsCommands(program: Command, handle: Handle): void;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// `oxygen connectors`: the apps a person connected to OXYGEN with OAuth
|
|
2
|
+
// (ChatGPT, Claude, Codex…) and the workspace each one works in without asking.
|
|
3
|
+
// The set command's flags come from the shared contract that also generates
|
|
4
|
+
// `oxygen_connector_update` and validates the route, so the CLI and MCP cannot
|
|
5
|
+
// disagree about what can be set.
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import { OxygenError } from "@oxygen/shared/cli-result";
|
|
8
|
+
import { CONNECTOR_UPDATE_OPERATION } from "@oxygen/shared/contracts/connectors";
|
|
9
|
+
import { operationPath } from "@oxygen/shared/contracts/operations";
|
|
10
|
+
import { addContractFlags, readContractFlags } from "./contract-flags.js";
|
|
11
|
+
import { requestOxygen } from "./http-client.js";
|
|
12
|
+
const update = CONNECTOR_UPDATE_OPERATION;
|
|
13
|
+
export const CONNECTORS_FLAG_OVERLAY = {
|
|
14
|
+
default_workspace: {
|
|
15
|
+
help: "The workspace this app should work in without asking: its id, slug or exact name from `orgs list`. You must be a member.",
|
|
16
|
+
valueName: "<workspace>",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
/** The PATCH body for the flags given; one of the two workspace flags is required. */
|
|
20
|
+
export function buildConnectorUpdateBody(options) {
|
|
21
|
+
const body = readContractFlags(options, update.contract, CONNECTORS_FLAG_OVERLAY);
|
|
22
|
+
if (!("default_workspace" in body)) {
|
|
23
|
+
throw new OxygenError("invalid_request", "Pass --default-workspace <workspace> to set where the app works, or --clear-default-workspace to have it ask each time.", { exitCode: 1 });
|
|
24
|
+
}
|
|
25
|
+
return body;
|
|
26
|
+
}
|
|
27
|
+
export function registerConnectorsCommands(program, handle) {
|
|
28
|
+
const connectors = program
|
|
29
|
+
.command("connectors")
|
|
30
|
+
.description("Connected apps: ChatGPT, Claude, Codex and any other app you connected to OXYGEN with OAuth, and the default workspace each one works in without asking. "
|
|
31
|
+
+ "`connectors list` shows them; `connectors set <app> --default-workspace <workspace>` changes that app only. "
|
|
32
|
+
+ "Picking a workspace inside an app changes the same setting, and the latest choice wins. This CLI keeps its own workspace (`orgs use`). "
|
|
33
|
+
+ "Web: https://oxygen-agent.com/settings/connected-apps.");
|
|
34
|
+
connectors.addCommand(new Command("list")
|
|
35
|
+
.description("List your connected apps with each one's default workspace, when it was last used, and the workspaces you can choose from. Read-only, 0 credits.")
|
|
36
|
+
.option("--json", "Print a JSON envelope.")
|
|
37
|
+
.action(async (options) => {
|
|
38
|
+
await handle("connectors list", options, () => requestOxygen("/api/cli/connectors"));
|
|
39
|
+
}));
|
|
40
|
+
connectors.addCommand(new Command("get")
|
|
41
|
+
.description("Show one connected app and its default workspace. Read-only, 0 credits.")
|
|
42
|
+
.argument("<connection>", update.cli.argument?.description ?? "The app's id or name.")
|
|
43
|
+
.option("--json", "Print a JSON envelope.")
|
|
44
|
+
.action(async (connection, options) => {
|
|
45
|
+
await handle("connectors get", options, () => requestOxygen(`/api/cli/connectors/${encodeURIComponent(connection)}`));
|
|
46
|
+
}));
|
|
47
|
+
connectors.addCommand(addContractFlags(new Command(update.cli.path.at(-1) ?? "set")
|
|
48
|
+
.description(update.cli.summary)
|
|
49
|
+
.argument(`<${update.cli.argument?.name ?? "connection"}>`, update.cli.argument?.description), update.contract, CONNECTORS_FLAG_OVERLAY)
|
|
50
|
+
.option("--json", "Print a JSON envelope.")
|
|
51
|
+
.addHelpText("after", `
|
|
52
|
+
Examples:
|
|
53
|
+
oxygen connectors list
|
|
54
|
+
oxygen connectors set ChatGPT --default-workspace "Acme GTM"
|
|
55
|
+
oxygen connectors set <id> --clear-default-workspace
|
|
56
|
+
`)
|
|
57
|
+
.action(async (connection, options) => {
|
|
58
|
+
await handle("connectors set", options, () => requestOxygen(operationPath(update, { connection }), {
|
|
59
|
+
method: update.method,
|
|
60
|
+
body: buildConnectorUpdateBody(options),
|
|
61
|
+
}));
|
|
62
|
+
}));
|
|
63
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { stdin as input, stdout as output } from "node:process";
|
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
9
|
import { Command, CommanderError, Option } from "commander";
|
|
10
10
|
import { registerUgcCommands } from "./ugc-commands.js";
|
|
11
|
+
import { registerConnectorsCommands } from "./connectors-commands.js";
|
|
11
12
|
import { registerKnowledgeRepositoryCommands } from "./knowledge-repository-commands.js";
|
|
12
13
|
import { registerVisualCommands } from "./visual-commands.js";
|
|
13
14
|
import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js";
|
|
@@ -3918,14 +3919,14 @@ export function createProgram() {
|
|
|
3918
3919
|
await handleOrgsListAction(options);
|
|
3919
3920
|
}))
|
|
3920
3921
|
.addCommand(new Command("use")
|
|
3921
|
-
.description("Select the active organization for this CLI profile.")
|
|
3922
|
+
.description("Select the active organization for this CLI profile. Apps connected with OAuth (ChatGPT, Claude…) keep their own default workspace: `oxygen connectors`.")
|
|
3922
3923
|
.argument("<organization>", "Organization id, Clerk org id, or slug returned by orgs list.")
|
|
3923
3924
|
.option("--json", "Print a JSON envelope.")
|
|
3924
3925
|
.action(async (organization, options) => {
|
|
3925
3926
|
await handleOrgUseAction(organization, options, "orgs use");
|
|
3926
3927
|
}))
|
|
3927
3928
|
.addCommand(new Command("select")
|
|
3928
|
-
.description("Alias for orgs use.")
|
|
3929
|
+
.description("Alias for orgs use: this CLI profile only. For ChatGPT, Claude or another connected app, use `oxygen connectors set`.")
|
|
3929
3930
|
.argument("<organization>", "Organization id, Clerk org id, or slug returned by orgs list.")
|
|
3930
3931
|
.option("--json", "Print a JSON envelope.")
|
|
3931
3932
|
.action(async (organization, options) => {
|
|
@@ -16323,7 +16324,7 @@ Examples:
|
|
|
16323
16324
|
});
|
|
16324
16325
|
}))
|
|
16325
16326
|
.addCommand(new Command("stats")
|
|
16326
|
-
.description("Show one sequence's persisted launch status and current operational state separately, including reasons, open work, lifetime sent/terminal-failed/deferral counts, and the funnel.
|
|
16327
|
+
.description("Show one sequence's persisted launch status and current operational state separately, including reasons, open work, lifetime sent/terminal-failed/deferral counts, and the funnel. Today's completed sends, reservations and remaining capacity appear in the default output and daily_budget. operational.reasons carries why work is waiting (outside_send_window, mailbox_spacing, daily_send_cap_reached, ...). For a per-day send series use `sequences analytics --sequence <id> --range <window>`.")
|
|
16327
16328
|
.argument("<sequence>", "Sequence id or slug.")
|
|
16328
16329
|
.option("--json", "Print a JSON envelope.")
|
|
16329
16330
|
.action(async (sequence, options) => {
|
|
@@ -19739,6 +19740,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
|
|
|
19739
19740
|
registerFunctionsCommands(program, handleAsyncAction);
|
|
19740
19741
|
registerUgcCommands(program, handleAsyncAction);
|
|
19741
19742
|
registerKnowledgeRepositoryCommands(program, handleAsyncAction);
|
|
19743
|
+
registerConnectorsCommands(program, handleAsyncAction);
|
|
19742
19744
|
// Last, so every command registered above is covered — including the ones the
|
|
19743
19745
|
// register* helpers add after applyOxygenHelp.
|
|
19744
19746
|
enableCommandSuggestions(program);
|
|
@@ -26443,6 +26445,7 @@ function formatSequenceReplyRollup(replies) {
|
|
|
26443
26445
|
function formatSequenceStatsHealth(data) {
|
|
26444
26446
|
const stats = recordValue(data.stats);
|
|
26445
26447
|
const operational = recordValue(stats.operational);
|
|
26448
|
+
const budget = recordValue(data.daily_budget);
|
|
26446
26449
|
const lines = [
|
|
26447
26450
|
stringValue(data.name) ?? stringValue(data.sequence_id) ?? "Sequence",
|
|
26448
26451
|
`Persisted status: ${stringValue(data.status) ?? "unknown"}`,
|
|
@@ -26451,6 +26454,14 @@ function formatSequenceStatsHealth(data) {
|
|
|
26451
26454
|
`Reasons: ${formatSequenceReasons(operational)}`,
|
|
26452
26455
|
`${formatFailureWindow(operational.failureAnalytics)} outcomes: ${formatSequenceFailureTotals(operational.failureAnalytics)}`,
|
|
26453
26456
|
];
|
|
26457
|
+
const sentToday = numericValue(budget.sends_used);
|
|
26458
|
+
if (sentToday !== null) {
|
|
26459
|
+
const reserved = numericValue(budget.sends_reserved);
|
|
26460
|
+
const remaining = numericValue(budget.sends_remaining);
|
|
26461
|
+
lines.push(`Today${stringValue(budget.day) ? ` (${budget.day} UTC)` : ""}: ${sentToday} sent`
|
|
26462
|
+
+ (reserved === null ? "" : ` · ${reserved} reserved`)
|
|
26463
|
+
+ ` · ${remaining === null ? "unlimited" : remaining} available`);
|
|
26464
|
+
}
|
|
26454
26465
|
const failureMix = formatCountRecord(recordValue(recordValue(operational.failureAnalytics).byFailureClass));
|
|
26455
26466
|
if (failureMix !== "—")
|
|
26456
26467
|
lines.push(`Failure mix: ${failureMix}`);
|
|
@@ -93,6 +93,10 @@ export declare const SEQUENCE_UNSUBSCRIBE_COMMAND = "oxygen sequences update <se
|
|
|
93
93
|
* Factories, so a caller can edit the object it gets.
|
|
94
94
|
*/
|
|
95
95
|
export declare const CAPABILITY_ROUTE_MCP_CALLS: {
|
|
96
|
+
connectedAppsList: () => {
|
|
97
|
+
tool: string;
|
|
98
|
+
arguments: {};
|
|
99
|
+
};
|
|
96
100
|
sequenceSendingSchedule: () => {
|
|
97
101
|
tool: string;
|
|
98
102
|
arguments: {
|
|
@@ -22,15 +22,16 @@ export const OXYGEN_CAPABILITY_ROUTES = [
|
|
|
22
22
|
id: "workspace-access",
|
|
23
23
|
layer: "Control",
|
|
24
24
|
primitive: null,
|
|
25
|
-
owns: "Authentication, CLI identities, API keys, sessions, organizations,
|
|
25
|
+
owns: "Authentication, CLI identities, API keys, sessions, organizations, active-workspace selection, and connected apps (ChatGPT, Claude, Codex…) with the default workspace each one works in.",
|
|
26
26
|
notFor: "Workspace GTM data, provider credentials, or execution inside a primitive.",
|
|
27
|
-
execution: "Resolve identity and select the hosted workspace before operating its primitives.",
|
|
27
|
+
execution: "Resolve identity and select the hosted workspace before operating its primitives. Each app connected with OAuth keeps its own default workspace: `connectors list` shows them and `connectors set` changes one app only; orgs select changes only the app or CLI that calls it.",
|
|
28
28
|
posture: "mixed",
|
|
29
|
-
gatewayTools: ["oxygen_whoami", "oxygen_orgs_list", "oxygen_orgs_select"],
|
|
30
|
-
gatewayCommands: ["whoami", "orgs list", "orgs select"],
|
|
29
|
+
gatewayTools: ["oxygen_whoami", "oxygen_orgs_list", "oxygen_orgs_select", "oxygen_connector_list", "oxygen_connector_update"],
|
|
30
|
+
gatewayCommands: ["whoami", "orgs list", "orgs select", "connectors list", "connectors set"],
|
|
31
31
|
skills: ["oxygen-quickstart"],
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// `mcp`: the host-driven MCP Events routes an OAuth connection's subscriptions go through (ADR 0030 §2).
|
|
33
|
+
endpointSections: ["api-keys", "auth", "connectors", "login-requests", "mcp", "orgs", "sessions", "settings", "whoami"],
|
|
34
|
+
intentTerms: ["login", "authenticate", "identity", "who am i", "workspace", "organization", "org", "api key", "session", "switch workspace", "connected app", "connected apps", "default workspace"],
|
|
34
35
|
},
|
|
35
36
|
{
|
|
36
37
|
id: "discovery-and-skills",
|
|
@@ -827,6 +828,7 @@ export const SEQUENCE_UNSUBSCRIBE_COMMAND = "oxygen sequences update <sequence>
|
|
|
827
828
|
* Factories, so a caller can edit the object it gets.
|
|
828
829
|
*/
|
|
829
830
|
export const CAPABILITY_ROUTE_MCP_CALLS = {
|
|
831
|
+
connectedAppsList: () => ({ tool: "oxygen_connector_list", arguments: {} }),
|
|
830
832
|
sequenceSendingSchedule: () => ({
|
|
831
833
|
tool: "oxygen_sequences_update",
|
|
832
834
|
arguments: { sequence: "<sequence>", sending_schedule: { timezone: "<IANA zone>", days: [1, 2, 3, 4, 5], start: "09:00", end: "17:00" } },
|
|
@@ -926,7 +928,30 @@ function isOpenLinkedInRequestsIntent(query) {
|
|
|
926
928
|
return /\b(connection|invitation|invite|connect) requests?\b/.test(query)
|
|
927
929
|
|| /\b(pending|open|unanswered|outstanding|received|sent) (linkedin )?(invitations?|invites?)\b/.test(query);
|
|
928
930
|
}
|
|
931
|
+
/**
|
|
932
|
+
* The apps a person connected with OAuth and where each one works: "make
|
|
933
|
+
* ChatGPT always use workspace X", "default workspace for my Claude
|
|
934
|
+
* connection", "list my connected apps". Both blind baselines (2026-09-30)
|
|
935
|
+
* searched exactly this and got only `orgs select`, which changes the calling
|
|
936
|
+
* app or CLI and never another app; "connection" alone also pulled in the
|
|
937
|
+
* LinkedIn connections card.
|
|
938
|
+
*/
|
|
939
|
+
function isConnectedAppsIntent(query) {
|
|
940
|
+
if (/\bconnected apps?\b/.test(query))
|
|
941
|
+
return true;
|
|
942
|
+
const connection = /\b(oauth (?:apps?|clients?|connections?|grants?)|mcp (?:clients?|connections?|connectors?))\b/.test(query);
|
|
943
|
+
const app = connection || /\b(chatgpt|claude|codex|ai (?:apps?|assistants?|clients?))\b/.test(query);
|
|
944
|
+
if (!app)
|
|
945
|
+
return false;
|
|
946
|
+
// An app name alone is not enough ("make Claude always use my brand voice"):
|
|
947
|
+
// the ask must be about which workspace it works in, or list the connections.
|
|
948
|
+
const workspaceChoice = /\b(workspaces?|organi[sz]ations?|orgs?)\b/.test(query)
|
|
949
|
+
&& /\b(default|always|without asking|which|per (?:app|connection|client))\b/.test(query);
|
|
950
|
+
return workspaceChoice || (connection && /\b(list|show|see|which|my)\b/.test(query));
|
|
951
|
+
}
|
|
929
952
|
function explicitCapabilityIntent(query) {
|
|
953
|
+
if (isConnectedAppsIntent(query))
|
|
954
|
+
return ROUTE_BY_ID.get("workspace-access") ?? null;
|
|
930
955
|
if (/\b(infographic|graphic designer|render html|carousel pages|visual design|gtm flow image)\b/.test(query))
|
|
931
956
|
return ROUTE_BY_ID.get("visual-rendering") ?? null;
|
|
932
957
|
if (isUgcIntent(query))
|
|
@@ -1236,6 +1261,14 @@ function recommendationsFor(card, query) {
|
|
|
1236
1261
|
exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.localBusinessScrape(),
|
|
1237
1262
|
};
|
|
1238
1263
|
}
|
|
1264
|
+
if (card.id === "workspace-access" && isConnectedAppsIntent(query)) {
|
|
1265
|
+
return {
|
|
1266
|
+
tools: ["oxygen_connector_list", "oxygen_connector_update"],
|
|
1267
|
+
commands: ["connectors list", "connectors set"],
|
|
1268
|
+
exactCommand: "oxygen connectors list --json",
|
|
1269
|
+
exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.connectedAppsList(),
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1239
1272
|
if (card.id === "connected-linkedin" && isLinkedInAccountConnectIntent(query)) {
|
|
1240
1273
|
return {
|
|
1241
1274
|
tools: ["oxygen_senders_connect", "oxygen_senders_list", "oxygen_billing_commitments"],
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PATCH /api/cli/connectors/{connection}`: the settings one connected app
|
|
3
|
+
* (an MCP OAuth client such as ChatGPT, Claude or Codex) keeps for itself.
|
|
4
|
+
*
|
|
5
|
+
* Today that is its default workspace: the workspace the app works in without
|
|
6
|
+
* asking. It is the same per-connection choice `oxygen_orgs_select` makes from
|
|
7
|
+
* inside the app, so either surface changes one setting and the latest choice
|
|
8
|
+
* wins. The contract is the extension point for later per-connection settings
|
|
9
|
+
* (a per-connection credit ceiling is planned, OXP-11.5.23): add the field here
|
|
10
|
+
* and the route, the CLI flags and the MCP tool follow.
|
|
11
|
+
*/
|
|
12
|
+
import type { OperationSpec } from "./operations.js";
|
|
13
|
+
import { type Infer } from "./schema.js";
|
|
14
|
+
export declare const CONNECTOR_UPDATE_CONTRACT: import("./schema.js").ContractObject<{
|
|
15
|
+
readonly default_workspace: {
|
|
16
|
+
oneOf: [import("./schema.js").StringProperty, import("./schema.js").NullProperty];
|
|
17
|
+
description?: string;
|
|
18
|
+
};
|
|
19
|
+
}, never>;
|
|
20
|
+
export type ConnectorUpdate = Infer<typeof CONNECTOR_UPDATE_CONTRACT>;
|
|
21
|
+
/** The route's path value for "the app making this request". */
|
|
22
|
+
export declare const CURRENT_CONNECTOR_REF = "current";
|
|
23
|
+
export declare const CONNECTOR_UPDATE_OPERATION: OperationSpec<typeof CONNECTOR_UPDATE_CONTRACT>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { nullable, obj, str } from "./schema.js";
|
|
2
|
+
export const CONNECTOR_UPDATE_CONTRACT = obj({
|
|
3
|
+
default_workspace: nullable(str({
|
|
4
|
+
minLength: 1,
|
|
5
|
+
maxLength: 200,
|
|
6
|
+
description: "Workspace id, slug or exact name (you must be a member). null = ask each time.",
|
|
7
|
+
})),
|
|
8
|
+
});
|
|
9
|
+
const inferMatchesRoute = true;
|
|
10
|
+
void inferMatchesRoute;
|
|
11
|
+
/** The route's path value for "the app making this request". */
|
|
12
|
+
export const CURRENT_CONNECTOR_REF = "current";
|
|
13
|
+
export const CONNECTOR_UPDATE_OPERATION = {
|
|
14
|
+
id: "connectors.update",
|
|
15
|
+
route: "/api/cli/connectors/{connection}",
|
|
16
|
+
method: "PATCH",
|
|
17
|
+
contract: CONNECTOR_UPDATE_CONTRACT,
|
|
18
|
+
cli: {
|
|
19
|
+
path: ["connectors", "set"],
|
|
20
|
+
summary: "Set the default workspace of one connected app (ChatGPT, Claude, Codex or another app connected to OXYGEN with OAuth), so that app works there without asking. Only that app changes; your other apps and this CLI keep their own workspace. It is the same setting the app changes when you pick a workspace inside it, so the latest choice wins. --clear-default-workspace makes the app ask again. Free; `connectors list` shows your apps and each one's current default.",
|
|
21
|
+
argument: {
|
|
22
|
+
name: "connection",
|
|
23
|
+
description: "The app's id from `connectors list`, or its name (e.g. ChatGPT) when you have one app of that name.",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
mcp: {
|
|
27
|
+
name: "oxygen_connector_update",
|
|
28
|
+
title: "Oxygen: Set Connected App Workspace",
|
|
29
|
+
description: "Sets the default workspace of one connected app (ChatGPT, Claude, Codex…), leaving your other apps unchanged. Same setting as picking a workspace inside that app; latest wins. connection: id or name from oxygen_connector_list, or \"current\" for this app. Free.",
|
|
30
|
+
pack: "workspace",
|
|
31
|
+
},
|
|
32
|
+
mutates: true,
|
|
33
|
+
spends: false,
|
|
34
|
+
};
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One entry per customer operation that edits a resource: the route it calls,
|
|
3
|
-
* the contract its body satisfies, and the names it carries on the CLI and MCP.
|
|
4
|
-
* The CLI command, the MCP tool and the route read the same spec, so the three
|
|
5
|
-
* cannot disagree on which fields exist. Leaf and browser-safe.
|
|
6
|
-
*/
|
|
7
1
|
import type { ContractObject } from "./schema.js";
|
|
8
2
|
export type OperationSpec<C extends ContractObject = ContractObject> = {
|
|
9
3
|
/** Stable id, `<resource>.<verb>`. */
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One entry per customer operation that edits a resource: the route it calls,
|
|
3
|
+
* the contract its body satisfies, and the names it carries on the CLI and MCP.
|
|
4
|
+
* The CLI command, the MCP tool and the route read the same spec, so the three
|
|
5
|
+
* cannot disagree on which fields exist. Leaf and browser-safe.
|
|
6
|
+
*/
|
|
7
|
+
import { CONNECTOR_UPDATE_OPERATION } from "./connectors.js";
|
|
1
8
|
import { SENDER_LIMITS_SET_OPERATION } from "./sender-limits.js";
|
|
2
9
|
/** The route with its `{param}` segments filled in, each URL-encoded. */
|
|
3
10
|
export function operationPath(spec, params) {
|
|
@@ -8,4 +15,4 @@ export function operationPath(spec, params) {
|
|
|
8
15
|
return encodeURIComponent(value);
|
|
9
16
|
});
|
|
10
17
|
}
|
|
11
|
-
export const OPERATION_SPECS = [SENDER_LIMITS_SET_OPERATION];
|
|
18
|
+
export const OPERATION_SPECS = [SENDER_LIMITS_SET_OPERATION, CONNECTOR_UPDATE_OPERATION];
|
|
@@ -80,6 +80,11 @@ export type FeatureGateResolution = {
|
|
|
80
80
|
* instead of a sending seat (`packages/shared/src/credit-gate.ts`). A wrong
|
|
81
81
|
* "on" while commitments do not bill every connectable kind would admit
|
|
82
82
|
* accounts nothing ever charges.
|
|
83
|
+
* - OXYGEN_MCP_EVENTS_ENABLED — (b). Lets workspace events leave OXYGEN as
|
|
84
|
+
* signed POSTs to third-party callback URLs and lets those events start
|
|
85
|
+
* unattended ChatGPT runs (`packages/shared/src/mcp-events-enabled.ts`, ADR
|
|
86
|
+
* 0030 §11). It stays off in production until the per-connection credit
|
|
87
|
+
* ceiling exists; a remote "on" would bypass that decision.
|
|
83
88
|
*
|
|
84
89
|
* The list is a guard, not a to-do: none of these is migrated, and
|
|
85
90
|
* `findFeatureGateRegistryViolations` fails the build's tests if one ever is.
|
|
@@ -71,6 +71,11 @@
|
|
|
71
71
|
* instead of a sending seat (`packages/shared/src/credit-gate.ts`). A wrong
|
|
72
72
|
* "on" while commitments do not bill every connectable kind would admit
|
|
73
73
|
* accounts nothing ever charges.
|
|
74
|
+
* - OXYGEN_MCP_EVENTS_ENABLED — (b). Lets workspace events leave OXYGEN as
|
|
75
|
+
* signed POSTs to third-party callback URLs and lets those events start
|
|
76
|
+
* unattended ChatGPT runs (`packages/shared/src/mcp-events-enabled.ts`, ADR
|
|
77
|
+
* 0030 §11). It stays off in production until the per-connection credit
|
|
78
|
+
* ceiling exists; a remote "on" would bypass that decision.
|
|
74
79
|
*
|
|
75
80
|
* The list is a guard, not a to-do: none of these is migrated, and
|
|
76
81
|
* `findFeatureGateRegistryViolations` fails the build's tests if one ever is.
|
|
@@ -87,6 +92,7 @@ export const NEVER_FLAGGABLE = [
|
|
|
87
92
|
"OXYGEN_BYOK_DAILY_CAPS",
|
|
88
93
|
"OXYGEN_REPRICING_2026_09_EFFECTIVE_AT",
|
|
89
94
|
"OXYGEN_CREDIT_GATE_ENABLED",
|
|
95
|
+
"OXYGEN_MCP_EVENTS_ENABLED",
|
|
90
96
|
];
|
|
91
97
|
/**
|
|
92
98
|
* Every server-side gate resolved through PostHog.
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* WHAT IT IS. Exactly once in a workspace's life, OXYGEN researches the customer's
|
|
5
5
|
* OWN company from the domain the creator typed when the workspace was created
|
|
6
|
-
* (control-DB `organizations.iconDomain`),
|
|
6
|
+
* (control-DB `organizations.iconDomain`), or from the company LinkedIn page they
|
|
7
|
+
* typed instead when the company has no website, then fills the typed company profile
|
|
7
8
|
* (`ox_context.company_profile`) and writes one cited wiki page. A founder who
|
|
8
9
|
* signs up should not face an empty Knowledge Graph and have to type their own
|
|
9
10
|
* positioning back at us; every grounded AI action downstream (message drafts, AI
|
|
@@ -25,8 +26,9 @@
|
|
|
25
26
|
* sub-ceiling on the provider (external-money) half. The cap bounds
|
|
26
27
|
* OXYGEN's own money now, and it is the same number.
|
|
27
28
|
* 2. AUTHORIZED — it runs only for a workspace whose creator typed the company
|
|
28
|
-
* website
|
|
29
|
-
*
|
|
29
|
+
* website, or its LinkedIn company page when it has no website
|
|
30
|
+
* (Philipp, 2026-09-30), into the REQUIRED field at workspace
|
|
31
|
+
* creation; that entry is recorded as `knowledge_bootstrap_consent` (source
|
|
30
32
|
* `workspace_creation`) and read FAIL-CLOSED, so a workspace that
|
|
31
33
|
* predates the field is never researched and a personal-email
|
|
32
34
|
* signup with no website never reaches the worker. We never
|
|
@@ -86,6 +88,17 @@ export declare const KNOWLEDGE_BOOTSTRAP_MAX_PAGES_READ = 5;
|
|
|
86
88
|
* it. This bounds latency only; the credit ceilings above remain the money guard.
|
|
87
89
|
*/
|
|
88
90
|
export declare const KNOWLEDGE_BOOTSTRAP_PROVIDER_TIMEOUT_MS = 45000;
|
|
91
|
+
/**
|
|
92
|
+
* The company-page lookup's budget when a LinkedIn company page, not a domain, names
|
|
93
|
+
* the company. That lookup is then the pass's only company evidence, and there is no
|
|
94
|
+
* website to fall back on. The managed lane measured successful company lookups at
|
|
95
|
+
* p90 37.1 s, p99 62.2 s and max 68.6 s over 3,138 calls to 2026-09-20
|
|
96
|
+
* (packages/providers/src/scraper/managed.ts), so the 45 s leg budget would abandon
|
|
97
|
+
* real answers. 90 s matches that lane's own abort budget. A pass with a website
|
|
98
|
+
* behind the page can overrun the step deadline in the worst case. The abort check
|
|
99
|
+
* between legs then ends it as `step_aborted`, and the credit ceilings are unchanged.
|
|
100
|
+
*/
|
|
101
|
+
export declare const KNOWLEDGE_BOOTSTRAP_COMPANY_PAGE_LOOKUP_TIMEOUT_MS = 90000;
|
|
89
102
|
/** Fleet-wide kill switch, matching OXYGEN_KNOWLEDGE_RETRIEVAL/AUTO_SOURCES_DISABLED. */
|
|
90
103
|
export declare const KNOWLEDGE_BOOTSTRAP_DISABLED_ENV_VAR = "OXYGEN_KNOWLEDGE_BOOTSTRAP_DISABLED";
|
|
91
104
|
/**
|
|
@@ -140,7 +153,11 @@ export type KnowledgeBootstrapMarker = {
|
|
|
140
153
|
run_id: string | null;
|
|
141
154
|
/** Managed credits actually spent, across every phase. Never exceeds the cap. */
|
|
142
155
|
credits_used: number;
|
|
143
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* The workspace domain researched (from organizations.iconDomain). Null (stored
|
|
158
|
+
* as "", the key the execution and onboarding checks compare on) for a company
|
|
159
|
+
* named by its LinkedIn page, which `linkedin_url` then carries.
|
|
160
|
+
*/
|
|
144
161
|
domain: string | null;
|
|
145
162
|
/** Why a pass was skipped/partial/failed — a short machine-ish token, not prose. */
|
|
146
163
|
reason: string | null;
|
|
@@ -163,7 +180,8 @@ export type KnowledgeBootstrapMarker = {
|
|
|
163
180
|
* Canonical company LinkedIn URL the requester supplied, when they supplied one.
|
|
164
181
|
* The same hint `/setup` stores at KNOWLEDGE_BOOTSTRAP_LINKEDIN_METADATA_KEY, but
|
|
165
182
|
* carried per-request so a one-off `--linkedin` never mutates the workspace's
|
|
166
|
-
* recorded answer.
|
|
183
|
+
* recorded answer. For a company named by its LinkedIn page it is the company
|
|
184
|
+
* itself: the page the automatic pass was claimed for.
|
|
167
185
|
*/
|
|
168
186
|
linkedin_url?: string | null;
|
|
169
187
|
};
|
|
@@ -180,11 +198,32 @@ export type KnowledgeBootstrapMarker = {
|
|
|
180
198
|
* nobody recorded.
|
|
181
199
|
*/
|
|
182
200
|
export declare const KNOWLEDGE_BOOTSTRAP_CONSENT_METADATA_KEY = "knowledge_bootstrap_consent";
|
|
201
|
+
/**
|
|
202
|
+
* Control-DB `organizations.metadata` key for the consent of a workspace named by its
|
|
203
|
+
* LinkedIn company page because the company has no website (Philipp, 2026-09-30).
|
|
204
|
+
*
|
|
205
|
+
* A key of its own, not a field on the consent above, on purpose. A worker built before
|
|
206
|
+
* it existed reads only that key, and a consent there with a page and no domain would
|
|
207
|
+
* send it to its icon fallback (a domain derived from the creator's email address, so
|
|
208
|
+
* possibly another company) or to a durable `no_company_domain` skip. Under this key the
|
|
209
|
+
* same worker finds no consent at all: a transient `not_consented` that writes nothing,
|
|
210
|
+
* until a worker that reads pages takes the pass. A rolling deploy serves new web code
|
|
211
|
+
* well before the worker roles move, so this is a real window, not a hypothetical one.
|
|
212
|
+
* Writers set exactly one of the two keys.
|
|
213
|
+
*/
|
|
214
|
+
export declare const KNOWLEDGE_BOOTSTRAP_COMPANY_PAGE_CONSENT_METADATA_KEY = "knowledge_bootstrap_company_page_consent";
|
|
183
215
|
/** The recorded consent, exactly as stored under the key above. */
|
|
184
216
|
export type KnowledgeBootstrapConsent = {
|
|
185
217
|
/** New workspace creation may authorize bounded creator-profile research. */
|
|
186
218
|
scope?: "company_and_creator";
|
|
187
219
|
domain?: string;
|
|
220
|
+
/**
|
|
221
|
+
* The company LinkedIn page the creator named INSTEAD of a website, canonical
|
|
222
|
+
* `/company/` form. Present only on a company-page consent
|
|
223
|
+
* (`KNOWLEDGE_BOOTSTRAP_COMPANY_PAGE_CONSENT_METADATA_KEY`), which never carries a
|
|
224
|
+
* domain; it is then the company the research pass starts from.
|
|
225
|
+
*/
|
|
226
|
+
company_linkedin_url?: string;
|
|
188
227
|
/** Canonical `/in/` profile supplied by this exact creator; never company evidence. */
|
|
189
228
|
creator_linkedin_url?: string;
|
|
190
229
|
/** ISO timestamp consent was recorded. Must parse; a marker that does not is not consent. */
|
|
@@ -222,11 +261,21 @@ export declare function readKnowledgeBootstrapConsent(metadata: unknown): Knowle
|
|
|
222
261
|
* personal profile as if it were the company is exactly the confidently-wrong write
|
|
223
262
|
* the bootstrap's domain gate exists to prevent.
|
|
224
263
|
*
|
|
225
|
-
* Optional
|
|
226
|
-
*
|
|
227
|
-
* has
|
|
264
|
+
* Optional when the workspace named a domain: the research pass then resolves the
|
|
265
|
+
* company from the domain. For a company with no website it is the only company
|
|
266
|
+
* identity the workspace has, and the consent carries the same page as the anchor
|
|
267
|
+
* the research starts from.
|
|
228
268
|
*/
|
|
229
269
|
export declare const KNOWLEDGE_BOOTSTRAP_LINKEDIN_METADATA_KEY = "company_linkedin_url";
|
|
270
|
+
/**
|
|
271
|
+
* The company LinkedIn page a workspace names instead of a website, as canonical
|
|
272
|
+
* `https://www.linkedin.com/company/<slug>`, or null. A pasted sub-page
|
|
273
|
+
* (`/company/acme/about/`) resolves to its company; `/school/` pages and personal
|
|
274
|
+
* `/in/` profiles do not, because the research provider looks a company up by
|
|
275
|
+
* exactly this form, and researching a person as if they were the company is the
|
|
276
|
+
* confidently-wrong write the authorization exists to prevent.
|
|
277
|
+
*/
|
|
278
|
+
export declare function normalizeBootstrapCompanyLinkedinUrl(value: unknown): string | null;
|
|
230
279
|
/**
|
|
231
280
|
* How long a `running` marker may sit before the workspace is reported as STUCK.
|
|
232
281
|
*
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* WHAT IT IS. Exactly once in a workspace's life, OXYGEN researches the customer's
|
|
5
5
|
* OWN company from the domain the creator typed when the workspace was created
|
|
6
|
-
* (control-DB `organizations.iconDomain`),
|
|
6
|
+
* (control-DB `organizations.iconDomain`), or from the company LinkedIn page they
|
|
7
|
+
* typed instead when the company has no website, then fills the typed company profile
|
|
7
8
|
* (`ox_context.company_profile`) and writes one cited wiki page. A founder who
|
|
8
9
|
* signs up should not face an empty Knowledge Graph and have to type their own
|
|
9
10
|
* positioning back at us; every grounded AI action downstream (message drafts, AI
|
|
@@ -25,8 +26,9 @@
|
|
|
25
26
|
* sub-ceiling on the provider (external-money) half. The cap bounds
|
|
26
27
|
* OXYGEN's own money now, and it is the same number.
|
|
27
28
|
* 2. AUTHORIZED — it runs only for a workspace whose creator typed the company
|
|
28
|
-
* website
|
|
29
|
-
*
|
|
29
|
+
* website, or its LinkedIn company page when it has no website
|
|
30
|
+
* (Philipp, 2026-09-30), into the REQUIRED field at workspace
|
|
31
|
+
* creation; that entry is recorded as `knowledge_bootstrap_consent` (source
|
|
30
32
|
* `workspace_creation`) and read FAIL-CLOSED, so a workspace that
|
|
31
33
|
* predates the field is never researched and a personal-email
|
|
32
34
|
* signup with no website never reaches the worker. We never
|
|
@@ -52,6 +54,7 @@
|
|
|
52
54
|
* Professional person evidence remains actor-scoped; ICP/offers are hypotheses.
|
|
53
55
|
* The provider sub-cap covers every external research leg, including fallbacks.
|
|
54
56
|
*/
|
|
57
|
+
import { normalizeLinkedinCompanyUrl } from "./dnc-identities.js";
|
|
55
58
|
import { normalizeLinkedinProfileUrl } from "./linkedin-url.js";
|
|
56
59
|
/** Hard ceiling on managed credits ONE workspace bootstrap may spend, all phases. */
|
|
57
60
|
export const KNOWLEDGE_BOOTSTRAP_MAX_CREDITS = 30;
|
|
@@ -86,6 +89,17 @@ export const KNOWLEDGE_BOOTSTRAP_MAX_PAGES_READ = 5;
|
|
|
86
89
|
* it. This bounds latency only; the credit ceilings above remain the money guard.
|
|
87
90
|
*/
|
|
88
91
|
export const KNOWLEDGE_BOOTSTRAP_PROVIDER_TIMEOUT_MS = 45_000;
|
|
92
|
+
/**
|
|
93
|
+
* The company-page lookup's budget when a LinkedIn company page, not a domain, names
|
|
94
|
+
* the company. That lookup is then the pass's only company evidence, and there is no
|
|
95
|
+
* website to fall back on. The managed lane measured successful company lookups at
|
|
96
|
+
* p90 37.1 s, p99 62.2 s and max 68.6 s over 3,138 calls to 2026-09-20
|
|
97
|
+
* (packages/providers/src/scraper/managed.ts), so the 45 s leg budget would abandon
|
|
98
|
+
* real answers. 90 s matches that lane's own abort budget. A pass with a website
|
|
99
|
+
* behind the page can overrun the step deadline in the worst case. The abort check
|
|
100
|
+
* between legs then ends it as `step_aborted`, and the credit ceilings are unchanged.
|
|
101
|
+
*/
|
|
102
|
+
export const KNOWLEDGE_BOOTSTRAP_COMPANY_PAGE_LOOKUP_TIMEOUT_MS = 90_000;
|
|
89
103
|
/** Fleet-wide kill switch, matching OXYGEN_KNOWLEDGE_RETRIEVAL/AUTO_SOURCES_DISABLED. */
|
|
90
104
|
export const KNOWLEDGE_BOOTSTRAP_DISABLED_ENV_VAR = "OXYGEN_KNOWLEDGE_BOOTSTRAP_DISABLED";
|
|
91
105
|
/**
|
|
@@ -149,6 +163,20 @@ export function isKnowledgeBootstrapStatus(value) {
|
|
|
149
163
|
* nobody recorded.
|
|
150
164
|
*/
|
|
151
165
|
export const KNOWLEDGE_BOOTSTRAP_CONSENT_METADATA_KEY = "knowledge_bootstrap_consent";
|
|
166
|
+
/**
|
|
167
|
+
* Control-DB `organizations.metadata` key for the consent of a workspace named by its
|
|
168
|
+
* LinkedIn company page because the company has no website (Philipp, 2026-09-30).
|
|
169
|
+
*
|
|
170
|
+
* A key of its own, not a field on the consent above, on purpose. A worker built before
|
|
171
|
+
* it existed reads only that key, and a consent there with a page and no domain would
|
|
172
|
+
* send it to its icon fallback (a domain derived from the creator's email address, so
|
|
173
|
+
* possibly another company) or to a durable `no_company_domain` skip. Under this key the
|
|
174
|
+
* same worker finds no consent at all: a transient `not_consented` that writes nothing,
|
|
175
|
+
* until a worker that reads pages takes the pass. A rolling deploy serves new web code
|
|
176
|
+
* well before the worker roles move, so this is a real window, not a hypothetical one.
|
|
177
|
+
* Writers set exactly one of the two keys.
|
|
178
|
+
*/
|
|
179
|
+
export const KNOWLEDGE_BOOTSTRAP_COMPANY_PAGE_CONSENT_METADATA_KEY = "knowledge_bootstrap_company_page_consent";
|
|
152
180
|
/**
|
|
153
181
|
* Read consent out of an organization's control-DB metadata blob, FAIL-CLOSED.
|
|
154
182
|
*
|
|
@@ -163,7 +191,22 @@ export const KNOWLEDGE_BOOTSTRAP_CONSENT_METADATA_KEY = "knowledge_bootstrap_con
|
|
|
163
191
|
export function readKnowledgeBootstrapConsent(metadata) {
|
|
164
192
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
|
|
165
193
|
return null;
|
|
166
|
-
const
|
|
194
|
+
const record = metadata;
|
|
195
|
+
const domainConsent = readGrant(record[KNOWLEDGE_BOOTSTRAP_CONSENT_METADATA_KEY]);
|
|
196
|
+
if (domainConsent) {
|
|
197
|
+
const domain = domainConsent.record.domain;
|
|
198
|
+
return { ...domainConsent.grant, ...(typeof domain === "string" ? { domain } : {}) };
|
|
199
|
+
}
|
|
200
|
+
// Fail closed here too: a page consent counts only as the creator-scoped grant it is
|
|
201
|
+
// written as, naming a page that still resolves to a company. Anything less is no
|
|
202
|
+
// consent, never a reason to fall back to a domain nobody named.
|
|
203
|
+
const pageConsent = readGrant(record[KNOWLEDGE_BOOTSTRAP_COMPANY_PAGE_CONSENT_METADATA_KEY]);
|
|
204
|
+
const page = normalizeBootstrapCompanyLinkedinUrl(pageConsent?.record.company_linkedin_url);
|
|
205
|
+
if (!pageConsent || pageConsent.grant.scope !== "company_and_creator" || !page)
|
|
206
|
+
return null;
|
|
207
|
+
return { ...pageConsent.grant, company_linkedin_url: page };
|
|
208
|
+
}
|
|
209
|
+
function readGrant(raw) {
|
|
167
210
|
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
168
211
|
return null;
|
|
169
212
|
const record = raw;
|
|
@@ -182,12 +225,14 @@ export function readKnowledgeBootstrapConsent(metadata) {
|
|
|
182
225
|
? normalizeLinkedinProfileUrl(record.creator_linkedin_url)?.dispatchUrl ?? null
|
|
183
226
|
: null;
|
|
184
227
|
return {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
228
|
+
record,
|
|
229
|
+
grant: {
|
|
230
|
+
granted_at: grantedAt,
|
|
231
|
+
...(scope ? { scope } : {}),
|
|
232
|
+
...(creatorLinkedinUrl ? { creator_linkedin_url: creatorLinkedinUrl } : {}),
|
|
233
|
+
source: typeof record.source === "string" && record.source.length > 0 ? record.source : null,
|
|
234
|
+
granted_by_clerk_user_id: grantedBy,
|
|
235
|
+
},
|
|
191
236
|
};
|
|
192
237
|
}
|
|
193
238
|
/**
|
|
@@ -203,11 +248,27 @@ export function readKnowledgeBootstrapConsent(metadata) {
|
|
|
203
248
|
* personal profile as if it were the company is exactly the confidently-wrong write
|
|
204
249
|
* the bootstrap's domain gate exists to prevent.
|
|
205
250
|
*
|
|
206
|
-
* Optional
|
|
207
|
-
*
|
|
208
|
-
* has
|
|
251
|
+
* Optional when the workspace named a domain: the research pass then resolves the
|
|
252
|
+
* company from the domain. For a company with no website it is the only company
|
|
253
|
+
* identity the workspace has, and the consent carries the same page as the anchor
|
|
254
|
+
* the research starts from.
|
|
209
255
|
*/
|
|
210
256
|
export const KNOWLEDGE_BOOTSTRAP_LINKEDIN_METADATA_KEY = "company_linkedin_url";
|
|
257
|
+
/**
|
|
258
|
+
* The company LinkedIn page a workspace names instead of a website, as canonical
|
|
259
|
+
* `https://www.linkedin.com/company/<slug>`, or null. A pasted sub-page
|
|
260
|
+
* (`/company/acme/about/`) resolves to its company; `/school/` pages and personal
|
|
261
|
+
* `/in/` profiles do not, because the research provider looks a company up by
|
|
262
|
+
* exactly this form, and researching a person as if they were the company is the
|
|
263
|
+
* confidently-wrong write the authorization exists to prevent.
|
|
264
|
+
*/
|
|
265
|
+
export function normalizeBootstrapCompanyLinkedinUrl(value) {
|
|
266
|
+
if (typeof value !== "string")
|
|
267
|
+
return null;
|
|
268
|
+
const companyRoot = value.trim().replace(/(\/company\/[^/?#]+)\/[^?#]*/i, "$1");
|
|
269
|
+
const canonical = normalizeLinkedinCompanyUrl(companyRoot);
|
|
270
|
+
return canonical?.startsWith("https://www.linkedin.com/company/") ? canonical : null;
|
|
271
|
+
}
|
|
211
272
|
/**
|
|
212
273
|
* How long a `running` marker may sit before the workspace is reported as STUCK.
|
|
213
274
|
*
|